diff --git a/.editorconfig b/.editorconfig index f67281eb..f6763f4d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -140,11 +140,17 @@ dotnet_diagnostic.IDE0001.severity = warning dotnet_diagnostic.IDE0002.severity = warning # IDE0005: Remove unnecessary import -dotnet_diagnostic.IDE0005.severity = warning +dotnet_diagnostic.IDE0005.severity = error # RS0041: Public members should not use oblivious types dotnet_diagnostic.RS0041.severity = suggestion +# CA2007: Do not directly await a Task +dotnet_diagnostic.CA2007.severity = error + +# IDE0161: Convert to file-scoped namespace +csharp_style_namespace_declarations = file_scoped:warning + [obj/**.cs] generated_code = true diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..9436647d --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,6 @@ +# These owners will be the default owners for everything in +# the repo. Unless a later match takes precedence +# +# Managed by Peribolos: https://github.com/open-feature/community/blob/main/config/open-feature/sdk-dotnet/workgroup.yaml +# +* @open-feature/sdk-dotnet-maintainers diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml new file mode 100644 index 00000000..8dc048af --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -0,0 +1,23 @@ +name: ๐Ÿž Bug +description: Found a bug? We are sorry about that! Let us know! ๐Ÿ› +title: "[BUG] " +labels: [bug, Needs Triage] +body: +- type: textarea + attributes: + label: Observed behavior + description: What are you trying to do? Describe what you think went wrong during this. + validations: + required: false +- type: textarea + attributes: + label: Expected Behavior + description: A concise description of what you expected to happen. + validations: + required: false +- type: textarea + attributes: + label: Steps to reproduce + description: Describe as best you can the problem. Please provide us scenario file, logs or anything you have that can help us to understand. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/documentation.yaml b/.github/ISSUE_TEMPLATE/documentation.yaml new file mode 100644 index 00000000..3c20743a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yaml @@ -0,0 +1,11 @@ +name: ๐Ÿ““ Documentation +description: Any documentation related issue/addition. +title: "[DOC] " +labels: [documentation, Needs Triage] +body: +- type: textarea + attributes: + label: Change in the documentation + description: What should we add/remove/update in the documentation? + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature.yaml b/.github/ISSUE_TEMPLATE/feature.yaml new file mode 100644 index 00000000..3833285d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yaml @@ -0,0 +1,14 @@ +name: ๐Ÿ’ก Feature +description: Add new functionality to the project. +title: "[FEATURE] " +labels: [enhancement, Needs Triage] +body: +- type: textarea + attributes: + label: Requirements + description: | + Ask us what you want! Please provide as many details as possible and describe how it should work. + + Note: Spec and architecture changes require an [OFEP](https://github.com/open-feature/ofep). + validations: + required: false diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index f1a4f656..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,8 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "daily" - labels: - - "dependency" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..bb1c7227 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,94 @@ +name: CI + +on: + push: + branches: [main] + paths-ignore: + - "**.md" + pull_request: + branches: [main] + paths-ignore: + - "**.md" + +jobs: + build: + permissions: + contents: read + pull-requests: write + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Setup .NET SDK + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + env: + NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + global-json-file: global.json + source-url: https://nuget.pkg.github.com/open-feature/index.json + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build -c Release --no-restore + + - name: Test + run: dotnet test -c Release --no-build --logger GitHubActions + + packaging: + needs: build + + permissions: + contents: read + packages: write + id-token: write + attestations: write + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Setup .NET SDK + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + env: + NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + global-json-file: global.json + source-url: https://nuget.pkg.github.com/open-feature/index.json + + - name: Restore + run: dotnet restore + + - name: Pack NuGet packages (CI versions) + if: startsWith(github.ref, 'refs/heads/') + run: dotnet pack -c Release --no-restore --version-suffix "ci.$(date -u +%Y%m%dT%H%M%S)+sha.${GITHUB_SHA:0:9}" + + - name: Pack NuGet packages (PR versions) + if: startsWith(github.ref, 'refs/pull/') + run: dotnet pack -c Release --no-restore --version-suffix "pr.$(date -u +%Y%m%dT%H%M%S)+sha.${GITHUB_SHA:0:9}" + + - name: Publish NuGet packages (base) + if: github.event.pull_request.head.repo.fork == false + run: dotnet nuget push "src/**/*.nupkg" --api-key "${{ secrets.GITHUB_TOKEN }}" --source https://nuget.pkg.github.com/open-feature/index.json + + - name: Publish NuGet packages (fork) + if: github.event.pull_request.head.repo.fork == true + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: nupkgs + path: src/**/*.nupkg diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 363091bb..a33413d8 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -2,42 +2,44 @@ name: Code Coverage on: push: - branches: [ main ] + branches: [main] paths-ignore: - - '**.md' + - "**.md" pull_request: - branches: [ main ] + branches: [main] paths-ignore: - - '**.md' + - "**.md" jobs: build-test-report: - runs-on: ubuntu-latest - + permissions: + contents: read + pull-requests: write strategy: matrix: - version: [net6.0] + os: [ubuntu-latest, windows-latest] - env: - OS: ${{ matrix.os }} + runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Install dependencies - run: dotnet restore - - - name: Build - run: dotnet build --no-restore /p:ContinuousIntegrationBuild=true - - - name: Test ${{ matrix.version }} - run: dotnet test --no-build --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=opencover - - - uses: codecov/codecov-action@v3.1.0 - with: - env_vars: OS - name: Code Coverage for ${{ matrix.os }} - fail_ci_if_error: true - verbose: true + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + fetch-depth: 0 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + env: + NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + global-json-file: global.json + source-url: https://nuget.pkg.github.com/open-feature/index.json + + - name: Run Test + run: dotnet test --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=opencover + + - uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5.4.2 + with: + name: Code Coverage for ${{ matrix.os }} + fail_ci_if_error: true + verbose: true + token: ${{ secrets.CODECOV_UPLOAD_TOKEN }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 008a42f0..50c33905 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -32,17 +32,17 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'csharp' ] + language: [ 'csharp', 'actions' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3 # โ„น๏ธ Command-line programs to run using the OS shell. # ๐Ÿ“š See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3 diff --git a/.github/workflows/dco-merge-group.yml b/.github/workflows/dco-merge-group.yml new file mode 100644 index 00000000..018589ea --- /dev/null +++ b/.github/workflows/dco-merge-group.yml @@ -0,0 +1,15 @@ +name: DCO +on: + merge_group: + +# Workaround because the DCO app doesn't run on a merge_group trigger +# https://github.com/dcoapp/app/pull/200 +jobs: + DCO: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + if: ${{ github.actor != 'renovate[bot]' }} + steps: + - run: echo "dummy DCO workflow (it won't run any check actually) to trigger by merge_group in order to enable merge queue" diff --git a/.github/workflows/dotnet-format.yml b/.github/workflows/dotnet-format.yml index d7da82b6..16799cf1 100644 --- a/.github/workflows/dotnet-format.yml +++ b/.github/workflows/dotnet-format.yml @@ -2,31 +2,25 @@ name: dotnet format on: push: - branches: [ main ] - paths: - - '**.cs' - - '.editorconfig' + branches: [main] pull_request: - branches: [ main ] - paths: - - '**.cs' - - '.editorconfig' + branches: [main] jobs: check-format: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - - name: Check out code - uses: actions/checkout@v3 + - name: Check out code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - name: Setup .NET Core 6.0 - uses: actions/setup-dotnet@v2 - with: - dotnet-version: 6.0.x + - name: Setup .NET SDK + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + global-json-file: global.json - - name: Install format tool - run: dotnet tool install -g dotnet-format - - - name: dotnet format - run: dotnet-format --folder --check + - name: dotnet format + run: dotnet format --verify-no-changes OpenFeature.sln diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..ae0ca839 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,38 @@ +name: E2E Test + +on: + pull_request: + types: + - opened + - synchronize + - reopened + branches: + - main + merge_group: + +jobs: + e2e-tests: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + fetch-depth: 0 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + env: + NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + global-json-file: global.json + source-url: https://nuget.pkg.github.com/open-feature/index.json + + - name: Initialize Tests + run: | + git submodule update --init --recursive + cp spec/specification/assets/gherkin/*.feature test/OpenFeature.E2ETests/Features/ + + - name: Run Tests + run: dotnet test test/OpenFeature.E2ETests/ --configuration Release --logger GitHubActions diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml new file mode 100644 index 00000000..f2307927 --- /dev/null +++ b/.github/workflows/lint-pr.yml @@ -0,0 +1,20 @@ +name: "Lint PR" + +on: + pull_request_target: + types: + - opened + - edited + - synchronize + +jobs: + main: + name: Validate PR title + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/linux-ci.yml b/.github/workflows/linux-ci.yml deleted file mode 100644 index ddad7023..00000000 --- a/.github/workflows/linux-ci.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Linux - -on: - push: - branches: [ main ] - paths-ignore: - - '**.md' - pull_request: - branches: [ main ] - paths-ignore: - - '**.md' - -jobs: - build-test: - runs-on: ubuntu-latest - - strategy: - matrix: - version: [net6.0] - - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Install dependencies - run: dotnet restore - - - name: Build - run: dotnet build --configuration Release --no-restore - - - name: Test ${{ matrix.version }} - run: dotnet test **/bin/**/${{ matrix.version }}/*.Tests.dll --configuration Release --no-build --logger:"console;verbosity=detailed" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..339c5c8d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,101 @@ +name: Run Release Release + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + permissions: + contents: write # for googleapis/release-please-action to create release commit + pull-requests: write # for googleapis/release-please-action to create release PR + packages: read # for internal nuget reading + + runs-on: ubuntu-latest + + steps: + - uses: googleapis/release-please-action@db8f2c60ee802b3748b512940dde88eabd7b7e01 # v3 + id: release + with: + command: manifest + token: ${{secrets.RELEASE_PLEASE_ACTION_TOKEN}} + default-branch: main + signoff: "OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com>" + release-type: simple + outputs: + release_created: ${{ steps.release.outputs.release_created }} + release_tag_name: ${{ steps.release.outputs.tag_name }} + + release: + environment: publish + runs-on: ubuntu-latest + needs: release-please + permissions: + id-token: write + contents: read + attestations: write + if: ${{ needs.release-please.outputs.release_created }} + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + fetch-depth: 0 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + env: + NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + global-json-file: global.json + source-url: https://nuget.pkg.github.com/open-feature/index.json + + - name: Install dependencies + run: dotnet restore + + - name: Pack + run: dotnet pack -c Release --no-restore + + - name: Publish to Nuget + run: dotnet nuget push "src/**/*.nupkg" --api-key "${{ secrets.NUGET_TOKEN }}" --source https://api.nuget.org/v3/index.json + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0 + with: + subject-path: "src/**/*.nupkg" + + sbom: + runs-on: ubuntu-latest + permissions: + contents: write # upload sbom to a release + needs: release-please + continue-on-error: true + if: ${{ needs.release-please.outputs.release_created }} + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + fetch-depth: 0 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + env: + NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + global-json-file: global.json + source-url: https://nuget.pkg.github.com/open-feature/index.json + + - name: Install CycloneDX.NET + run: dotnet tool install CycloneDX + + - name: Generate .NET BOM + run: dotnet CycloneDX --json --exclude-dev -sv "${{ needs.release-please.outputs.release_tag_name }}" ./src/OpenFeature/OpenFeature.csproj + + - name: Attach SBOM to artifact + env: + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + run: gh release upload ${{ needs.release-please.outputs.release_tag_name }} bom.json diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml deleted file mode 100644 index 11119320..00000000 --- a/.github/workflows/windows-ci.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Windows - -on: - push: - branches: [ main ] - paths-ignore: - - '**.md' - pull_request: - branches: [ main ] - paths-ignore: - - '**.md' - -jobs: - build-test: - runs-on: windows-latest - - strategy: - matrix: - version: [net462,net6.0] - - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Install dependencies - run: dotnet restore - - - name: Build - run: dotnet build --configuration Release --no-restore - - - name: Test ${{ matrix.version }} - run: dotnet test **\bin\**\${{ matrix.version }}\*Tests.dll --configuration Release --no-build --logger:"console;verbosity=detailed" diff --git a/.gitignore b/.gitignore index 8b91dfe1..055ffe50 100644 --- a/.gitignore +++ b/.gitignore @@ -341,3 +341,16 @@ ASALocalRun/ /.sonarqube /src/LastMajorVersionBinaries + +# vscode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# integration tests +test/OpenFeature.E2ETests/Features/*.feature +test/OpenFeature.E2ETests/Features/*.feature.cs +cs-report.json +specification.json diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..85115b56 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "spec"] + path = spec + url = https://github.com/open-feature/spec.git diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 00000000..78baf5bf --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "2.5.0" +} diff --git a/.specrc b/.specrc new file mode 100644 index 00000000..03435c01 --- /dev/null +++ b/.specrc @@ -0,0 +1,5 @@ +[spec] +file_extension=cs +multiline_regex=\[Specification\((?P.*?)\)\] +number_subregex=([\d.]+) +text_subregex=,\s+"(.*)" diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..45138dd7 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "cucumberopen.cucumber-official", + "ms-dotnettools.csdevkit" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..c530628b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,26 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md + "name": ".NET Core Launch (console)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/test/OpenFeature.Tests/bin/Debug/net9.0/OpenFeature.Tests.dll", + "args": [], + "cwd": "${workspaceFolder}/test/OpenFeature.Tests", + // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console + "console": "internalConsole", + "stopAtEntry": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..a5d74da8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,22 @@ +{ + "explorer.fileNesting.enabled": true, + "explorer.fileNesting.patterns": { + // shows *.feature.cs files as nested items + "*.feature": "${capture}.feature.cs" + }, + "files.exclude": { + // excludes compilation result + "**/obj/": true, + "**/bin/": true, + "BenchmarkDotNet.Artifacts/": true, + ".idea/": true + }, + "cucumber.glue": [ + // sets the location of the step definition classes + "test/OpenFeature.E2ETests/Steps/*.cs" + ], + "cucumber.features": [ + // sets the location of the feature files + "test/OpenFeature.E2ETests/Features/*.feature" + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..b752a031 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/test/OpenFeature.Tests/OpenFeature.Tests.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/test/OpenFeature.Tests/OpenFeature.Tests.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/test/OpenFeature.Tests/OpenFeature.Tests.csproj" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..beebbd13 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,504 @@ +# Changelog + +## [2.5.0](https://github.com/open-feature/dotnet-sdk/compare/v2.4.0...v2.5.0) (2025-04-25) + + +### โœจ New Features + +* Add support for hook data. ([#387](https://github.com/open-feature/dotnet-sdk/issues/387)) ([4563512](https://github.com/open-feature/dotnet-sdk/commit/456351216ce9113d84b56d0bce1dad39430a26cd)) + + +### ๐Ÿงน Chore + +* add NuGet auditing ([#454](https://github.com/open-feature/dotnet-sdk/issues/454)) ([42ab536](https://github.com/open-feature/dotnet-sdk/commit/42ab5368d3d8f874f175ab9ad3077f177a592398)) +* Change file scoped namespaces and cleanup job ([#453](https://github.com/open-feature/dotnet-sdk/issues/453)) ([1e74a04](https://github.com/open-feature/dotnet-sdk/commit/1e74a04f2b76c128a09c95dfd0b06803f2ef77bf)) +* **deps:** update codecov/codecov-action action to v5.4.2 ([#432](https://github.com/open-feature/dotnet-sdk/issues/432)) ([c692ec2](https://github.com/open-feature/dotnet-sdk/commit/c692ec2a26eb4007ff428e54eaa67ea22fd20728)) +* **deps:** update github/codeql-action digest to 28deaed ([#446](https://github.com/open-feature/dotnet-sdk/issues/446)) ([dfecd0c](https://github.com/open-feature/dotnet-sdk/commit/dfecd0c6a4467e5c1afe481e785e3e0f179beb25)) +* **deps:** update spec digest to 18cde17 ([#395](https://github.com/open-feature/dotnet-sdk/issues/395)) ([5608dfb](https://github.com/open-feature/dotnet-sdk/commit/5608dfbd441b99531add8e89ad842ea9d613f707)) +* **deps:** update spec digest to 2ba05d8 ([#452](https://github.com/open-feature/dotnet-sdk/issues/452)) ([eb688c4](https://github.com/open-feature/dotnet-sdk/commit/eb688c412983511c7ec0744df95e4a113f610c5f)) +* **deps:** update spec digest to 36944c6 ([#450](https://github.com/open-feature/dotnet-sdk/issues/450)) ([e162169](https://github.com/open-feature/dotnet-sdk/commit/e162169af0b5518f12527a8601d6dfcdf379b4f7)) +* **deps:** update spec digest to d27e000 ([#455](https://github.com/open-feature/dotnet-sdk/issues/455)) ([e0ec8ca](https://github.com/open-feature/dotnet-sdk/commit/e0ec8ca28303b7df71699063b02b6967cdc37bcd)) +* packages read in release please ([1acc00f](https://github.com/open-feature/dotnet-sdk/commit/1acc00fa7a6a38152d97fd7efc9f7e8befb1c3ed)) +* update release permissions ([d0bf40b](https://github.com/open-feature/dotnet-sdk/commit/d0bf40b9b40adc57a2a008a9497098b3cd1a05a7)) +* **workflows:** Add permissions for contents and pull-requests ([#439](https://github.com/open-feature/dotnet-sdk/issues/439)) ([568722a](https://github.com/open-feature/dotnet-sdk/commit/568722a4ab1f863d8509dc4a172ac9c29f267825)) + + +### ๐Ÿ“š Documentation + +* update documentation on SetProviderAsync ([#449](https://github.com/open-feature/dotnet-sdk/issues/449)) ([858b286](https://github.com/open-feature/dotnet-sdk/commit/858b286dba2313239141c20ec6770504d340fbe0)) +* Update README with spec version ([#437](https://github.com/open-feature/dotnet-sdk/issues/437)) ([7318b81](https://github.com/open-feature/dotnet-sdk/commit/7318b8126df9f0ddd5651fdd9fe32da2e4819290)), closes [#204](https://github.com/open-feature/dotnet-sdk/issues/204) + + +### ๐Ÿ”„ Refactoring + +* InMemoryProvider throwing when types mismatched ([#442](https://github.com/open-feature/dotnet-sdk/issues/442)) ([8ecf50d](https://github.com/open-feature/dotnet-sdk/commit/8ecf50db2cab3a266de5c6c5216714570cfc6a52)) + +## [2.4.0](https://github.com/open-feature/dotnet-sdk/compare/v2.3.2...v2.4.0) (2025-04-14) + + +### ๐Ÿ› Bug Fixes + +* Refactor error handling and improve documentation ([#417](https://github.com/open-feature/dotnet-sdk/issues/417)) ([b0b168f](https://github.com/open-feature/dotnet-sdk/commit/b0b168ffc051e3a6c55f66ea6af4208e7d64419d)) + + +### โœจ New Features + +* update FeatureLifecycleStateOptions.StopState default to Stopped ([#414](https://github.com/open-feature/dotnet-sdk/issues/414)) ([6c23f21](https://github.com/open-feature/dotnet-sdk/commit/6c23f21d56ef6cc6adce7f798ee302924c227e1f)) + + +### ๐Ÿงน Chore + +* **deps:** update github/codeql-action digest to 45775bd ([#419](https://github.com/open-feature/dotnet-sdk/issues/419)) ([2bed467](https://github.com/open-feature/dotnet-sdk/commit/2bed467317ab0afa6d3e3718e89a5bb05453d649)) +* restrict publish to environment ([#431](https://github.com/open-feature/dotnet-sdk/issues/431)) ([0c222cb](https://github.com/open-feature/dotnet-sdk/commit/0c222cb5e90203e8f4740207d3dd82ec12179594)) + + +### ๐Ÿ“š Documentation + +* Update contributing guidelines ([#413](https://github.com/open-feature/dotnet-sdk/issues/413)) ([84ea288](https://github.com/open-feature/dotnet-sdk/commit/84ea288a3bc6e5ec8a797312f36e44c28d03c95c)) + + +### ๐Ÿ”„ Refactoring + +* simplify the InternalsVisibleTo usage ([#408](https://github.com/open-feature/dotnet-sdk/issues/408)) ([4043d3d](https://github.com/open-feature/dotnet-sdk/commit/4043d3d7610b398e6be035a0e1bf28e7c81ebf18)) + +## [2.3.2](https://github.com/open-feature/dotnet-sdk/compare/v2.3.1...v2.3.2) (2025-03-24) + + +### ๐Ÿ› Bug Fixes + +* Address issue with newline characters when running Logging Hook Unit Tests on linux ([#374](https://github.com/open-feature/dotnet-sdk/issues/374)) ([a98334e](https://github.com/open-feature/dotnet-sdk/commit/a98334edfc0a6a14ff60e362bd7aa198b70ff255)) +* Remove virtual GetEventChannel from FeatureProvider ([#401](https://github.com/open-feature/dotnet-sdk/issues/401)) ([00a4e4a](https://github.com/open-feature/dotnet-sdk/commit/00a4e4ab2ccb8984cd3ca57bad6d25e688b1cf8c)) +* Update project name in solution file ([#380](https://github.com/open-feature/dotnet-sdk/issues/380)) ([1f13258](https://github.com/open-feature/dotnet-sdk/commit/1f13258737fa051289d51cf5a064e03b0dc936c8)) + + +### ๐Ÿงน Chore + +* Correct LoggingHookTest timestamp handling. ([#386](https://github.com/open-feature/dotnet-sdk/issues/386)) ([c69a6e5](https://github.com/open-feature/dotnet-sdk/commit/c69a6e5d71a6d652017a0d46c8390554a1dec59e)) +* **deps:** update actions/setup-dotnet digest to 67a3573 ([#402](https://github.com/open-feature/dotnet-sdk/issues/402)) ([2e2c489](https://github.com/open-feature/dotnet-sdk/commit/2e2c4898479b3544d663c08ddd2dc011ca482b43)) +* **deps:** update actions/upload-artifact action to v4.6.1 ([#385](https://github.com/open-feature/dotnet-sdk/issues/385)) ([accf571](https://github.com/open-feature/dotnet-sdk/commit/accf57181b34c600cb775a93b173f644d8c445d1)) +* **deps:** update actions/upload-artifact action to v4.6.2 ([#406](https://github.com/open-feature/dotnet-sdk/issues/406)) ([16c92b7](https://github.com/open-feature/dotnet-sdk/commit/16c92b7814f49aceab6e6d46a8835c2bdc0f3363)) +* **deps:** update codecov/codecov-action action to v5.4.0 ([#392](https://github.com/open-feature/dotnet-sdk/issues/392)) ([06e4e3a](https://github.com/open-feature/dotnet-sdk/commit/06e4e3a7ee11aff5c53eeba2259a840956bc4d5d)) +* **deps:** update dependency dotnet-sdk to v9.0.202 ([#405](https://github.com/open-feature/dotnet-sdk/issues/405)) ([a4beaae](https://github.com/open-feature/dotnet-sdk/commit/a4beaaea375b3184578d259cd5ca481d23055a54)) +* **deps:** update dependency microsoft.net.test.sdk to 17.13.0 ([#375](https://github.com/open-feature/dotnet-sdk/issues/375)) ([7a735f8](https://github.com/open-feature/dotnet-sdk/commit/7a735f8d8b82b79b205f71716e5cf300a7fff276)) +* **deps:** update dependency reqnroll.xunit to 2.3.0 ([#378](https://github.com/open-feature/dotnet-sdk/issues/378)) ([96ba568](https://github.com/open-feature/dotnet-sdk/commit/96ba5686c2ba31996603f464fe7e5df9efa01a92)) +* **deps:** update dependency reqnroll.xunit to 2.4.0 ([#396](https://github.com/open-feature/dotnet-sdk/issues/396)) ([b30350b](https://github.com/open-feature/dotnet-sdk/commit/b30350bd49f4a8709b69a3eb2db1152d5a4b7f6c)) +* **deps:** update dependency system.valuetuple to 4.6.0 ([#403](https://github.com/open-feature/dotnet-sdk/issues/403)) ([75468d2](https://github.com/open-feature/dotnet-sdk/commit/75468d28ba4d8200c7199fe89d6d1a63f3bdd674)) +* **deps:** update dotnet monorepo ([#379](https://github.com/open-feature/dotnet-sdk/issues/379)) ([53ced91](https://github.com/open-feature/dotnet-sdk/commit/53ced9118ffcb8cda5142dc2f80465416922030b)) +* **deps:** update dotnet monorepo to 9.0.2 ([#377](https://github.com/open-feature/dotnet-sdk/issues/377)) ([3bdc79b](https://github.com/open-feature/dotnet-sdk/commit/3bdc79bbaa8d73c4747916d307c431990397cdde)) +* **deps:** update github/codeql-action digest to 1b549b9 ([#407](https://github.com/open-feature/dotnet-sdk/issues/407)) ([ae9fc79](https://github.com/open-feature/dotnet-sdk/commit/ae9fc79bcb9847efcb62673f5aa59df403cece78)) +* **deps:** update github/codeql-action digest to 5f8171a ([#404](https://github.com/open-feature/dotnet-sdk/issues/404)) ([73a5040](https://github.com/open-feature/dotnet-sdk/commit/73a504022d8ba4cbe508a4f0b76f9b73f58c17a6)) +* **deps:** update github/codeql-action digest to 6bb031a ([#398](https://github.com/open-feature/dotnet-sdk/issues/398)) ([9b6feab](https://github.com/open-feature/dotnet-sdk/commit/9b6feab50085ee7dfcca190fe42f583c072ae50d)) +* **deps:** update github/codeql-action digest to 9e8d078 ([#371](https://github.com/open-feature/dotnet-sdk/issues/371)) ([e74e8e7](https://github.com/open-feature/dotnet-sdk/commit/e74e8e7a58d90e46bbcd5d7e9433545412e07bbd)) +* **deps:** update github/codeql-action digest to b56ba49 ([#384](https://github.com/open-feature/dotnet-sdk/issues/384)) ([cc2990f](https://github.com/open-feature/dotnet-sdk/commit/cc2990ff8e7bf5148ab1cd867d9bfabfc0b7af8a)) +* **deps:** update spec digest to 0cd553d ([#389](https://github.com/open-feature/dotnet-sdk/issues/389)) ([85075ac](https://github.com/open-feature/dotnet-sdk/commit/85075ac7f46783dd1bcfdbbe6bd10d81eb9adb8a)) +* **deps:** update spec digest to 54952f3 ([#373](https://github.com/open-feature/dotnet-sdk/issues/373)) ([1e8b230](https://github.com/open-feature/dotnet-sdk/commit/1e8b2307369710ea0b5ae0e8a8f1f1293ea066dc)) +* **deps:** update spec digest to a69f748 ([#382](https://github.com/open-feature/dotnet-sdk/issues/382)) ([4977542](https://github.com/open-feature/dotnet-sdk/commit/4977542515bff302c7a88f3fa301bb129d7ea8cf)) +* remove FluentAssertions ([#361](https://github.com/open-feature/dotnet-sdk/issues/361)) ([4ecfd24](https://github.com/open-feature/dotnet-sdk/commit/4ecfd249181cf8fe372810a1fc3369347c6302fc)) +* Replace SpecFlow with Reqnroll for testing framework ([#368](https://github.com/open-feature/dotnet-sdk/issues/368)) ([ed6ee2c](https://github.com/open-feature/dotnet-sdk/commit/ed6ee2c502b16e49c91c6363ae6b3f54401a85cb)), closes [#354](https://github.com/open-feature/dotnet-sdk/issues/354) +* update release please repo, specify action permissions ([#369](https://github.com/open-feature/dotnet-sdk/issues/369)) ([63846ad](https://github.com/open-feature/dotnet-sdk/commit/63846ad1033399e9c84ad5946367c5eef2663b5b)) + + +### ๐Ÿ”„ Refactoring + +* Improve EventExecutor ([#393](https://github.com/open-feature/dotnet-sdk/issues/393)) ([46274a2](https://github.com/open-feature/dotnet-sdk/commit/46274a21d74b5cfffd4cfbc30e5e49e2dc1f256c)) + +## [2.3.1](https://github.com/open-feature/dotnet-sdk/compare/v2.3.0...v2.3.1) (2025-02-04) + + +### ๐Ÿ› Bug Fixes + +* Fix SBOM release pipeline ([#367](https://github.com/open-feature/dotnet-sdk/issues/367)) ([dad6282](https://github.com/open-feature/dotnet-sdk/commit/dad62826404e1d2e679ef35560a1dd858c95ffdc)) + + +### ๐Ÿงน Chore + +* **deps:** pin dependencies ([#365](https://github.com/open-feature/dotnet-sdk/issues/365)) ([3160cd2](https://github.com/open-feature/dotnet-sdk/commit/3160cd2262739ba0a2981a9dc04fc0d278799546)) +* **deps:** update actions/upload-artifact action to v4.6.0 ([#341](https://github.com/open-feature/dotnet-sdk/issues/341)) ([cb7105b](https://github.com/open-feature/dotnet-sdk/commit/cb7105b21c4e0fc1674365f9fd6a4b26e95f45c3)) +* **deps:** update dependency autofixture to 5.0.0-preview0012 ([#351](https://github.com/open-feature/dotnet-sdk/issues/351)) ([9b0b319](https://github.com/open-feature/dotnet-sdk/commit/9b0b3195fa206f11c1acc7336c4e4f6252b8b2ad)) +* **deps:** update dependency coverlet.collector to 6.0.4 ([#347](https://github.com/open-feature/dotnet-sdk/issues/347)) ([e59034d](https://github.com/open-feature/dotnet-sdk/commit/e59034dd56038351f79a7e226adae268d172ccbb)) +* **deps:** update dependency coverlet.msbuild to 6.0.4 ([#348](https://github.com/open-feature/dotnet-sdk/issues/348)) ([5ebe4f6](https://github.com/open-feature/dotnet-sdk/commit/5ebe4f685ec0bf4d7d0acbf3790c908e04c5efd7)) +* **deps:** update dependency xunit to 2.9.3 ([#340](https://github.com/open-feature/dotnet-sdk/issues/340)) ([fb8e5aa](https://github.com/open-feature/dotnet-sdk/commit/fb8e5aa9d3a020de3ea57948130951d0d282f465)) +* **deps:** update dotnet monorepo ([#343](https://github.com/open-feature/dotnet-sdk/issues/343)) ([32dab9b](https://github.com/open-feature/dotnet-sdk/commit/32dab9ba0904a6b27fc53e21402ae95d5594ad01)) +* **deps:** update spec digest to 8d6eeb3 ([#366](https://github.com/open-feature/dotnet-sdk/issues/366)) ([0cb58db](https://github.com/open-feature/dotnet-sdk/commit/0cb58db59573f9f8266fc417083c91e86e499772)) +* update renovate config to extend the shared config ([#364](https://github.com/open-feature/dotnet-sdk/issues/364)) ([e3965db](https://github.com/open-feature/dotnet-sdk/commit/e3965dbc31561c9a09342f8808f1175974e14317)) + +## [2.3.0](https://github.com/open-feature/dotnet-sdk/compare/v2.2.0...v2.3.0) (2025-01-31) + + +#### Hook Changes + +The signature of the `finally` hook stage has been changed. The signature now includes the `evaluation details`, as per the [OpenFeature specification](https://openfeature.dev/specification/sections/hooks#requirement-438). Note that since hooks are still `experimental,` this does not constitute a change requiring a new major version. To migrate, update any hook that implements the `finally` stage to accept `evaluation details` as the second argument. + +* Add evaluation details to finally hook stage ([#335](https://github.com/open-feature/dotnet-sdk/issues/335)) ([2ef9955](https://github.com/open-feature/dotnet-sdk/commit/2ef995529d377826d467fa486f18af20bfeeba60)) + +#### .NET 6 + +Removed support for .NET 6. + +* add dotnet 9 support, rm dotnet 6 ([#317](https://github.com/open-feature/dotnet-sdk/issues/317)) ([2774b0d](https://github.com/open-feature/dotnet-sdk/commit/2774b0d3c09f2f206834ca3fe2526e3eb3ca8087)) + +### ๐Ÿ› Bug Fixes + +* Adding Async Lifetime method to fix flaky unit tests ([#333](https://github.com/open-feature/dotnet-sdk/issues/333)) ([e14ab39](https://github.com/open-feature/dotnet-sdk/commit/e14ab39180d38544132e9fe92244b7b37255d2cf)) +* Fix issue with DI documentation ([#350](https://github.com/open-feature/dotnet-sdk/issues/350)) ([728ae47](https://github.com/open-feature/dotnet-sdk/commit/728ae471625ab1ff5f166b60a5830afbaf9ad276)) + + +### โœจ New Features + +* add dotnet 9 support, rm dotnet 6 ([#317](https://github.com/open-feature/dotnet-sdk/issues/317)) ([2774b0d](https://github.com/open-feature/dotnet-sdk/commit/2774b0d3c09f2f206834ca3fe2526e3eb3ca8087)) +* Add evaluation details to finally hook stage ([#335](https://github.com/open-feature/dotnet-sdk/issues/335)) ([2ef9955](https://github.com/open-feature/dotnet-sdk/commit/2ef995529d377826d467fa486f18af20bfeeba60)) +* Implement Default Logging Hook ([#308](https://github.com/open-feature/dotnet-sdk/issues/308)) ([7013e95](https://github.com/open-feature/dotnet-sdk/commit/7013e9503f6721bd5f241c6c4d082a4a4e9eceed)) +* Implement transaction context ([#312](https://github.com/open-feature/dotnet-sdk/issues/312)) ([1b5a0a9](https://github.com/open-feature/dotnet-sdk/commit/1b5a0a9823e4f68e9356536ad5aa8418d8ca815f)) + + +### ๐Ÿงน Chore + +* **deps:** update actions/upload-artifact action to v4.5.0 ([#332](https://github.com/open-feature/dotnet-sdk/issues/332)) ([fd68cb0](https://github.com/open-feature/dotnet-sdk/commit/fd68cb0bed0228607cc2369ef6822dd518c5fbec)) +* **deps:** update codecov/codecov-action action to v5 ([#316](https://github.com/open-feature/dotnet-sdk/issues/316)) ([6c4cd02](https://github.com/open-feature/dotnet-sdk/commit/6c4cd0273f85bc0be0b07753d47bf13a613bbf82)) +* **deps:** update codecov/codecov-action action to v5.1.2 ([#334](https://github.com/open-feature/dotnet-sdk/issues/334)) ([b9ebddf](https://github.com/open-feature/dotnet-sdk/commit/b9ebddfccb094f45a50e8196e43c087b4e97ffa4)) +* **deps:** update codecov/codecov-action action to v5.3.1 ([#355](https://github.com/open-feature/dotnet-sdk/issues/355)) ([1e8ebc4](https://github.com/open-feature/dotnet-sdk/commit/1e8ebc447f5f0d76cfb6e03d034d663ae0c32830)) +* **deps:** update dependency coverlet.collector to 6.0.3 ([#336](https://github.com/open-feature/dotnet-sdk/issues/336)) ([8527b03](https://github.com/open-feature/dotnet-sdk/commit/8527b03fb020a9604463da80f305978baa85f172)) +* **deps:** update dependency coverlet.msbuild to 6.0.3 ([#337](https://github.com/open-feature/dotnet-sdk/issues/337)) ([26fd235](https://github.com/open-feature/dotnet-sdk/commit/26fd2356c1835271dee2f7b8b03b2c83e9cb2eea)) +* **deps:** update dependency dotnet-sdk to v9.0.101 ([#339](https://github.com/open-feature/dotnet-sdk/issues/339)) ([dd26ad6](https://github.com/open-feature/dotnet-sdk/commit/dd26ad6d35e134ab40a290e644d5f8bdc8e56c66)) +* **deps:** update dependency fluentassertions to 7.1.0 ([#346](https://github.com/open-feature/dotnet-sdk/issues/346)) ([dd1c8e4](https://github.com/open-feature/dotnet-sdk/commit/dd1c8e4f78bf17b5fdb36a070a517a5fff0546d2)) +* **deps:** update dependency microsoft.net.test.sdk to 17.12.0 ([#322](https://github.com/open-feature/dotnet-sdk/issues/322)) ([6f5b049](https://github.com/open-feature/dotnet-sdk/commit/6f5b04997aee44c2023e75471932e9f5ff27b0be)) + + +### ๐Ÿ“š Documentation + +* disable space in link text lint rule ([#329](https://github.com/open-feature/dotnet-sdk/issues/329)) ([583b2a9](https://github.com/open-feature/dotnet-sdk/commit/583b2a9beab18ba70f8789b903d61a4c685560f0)) + +## [2.2.0](https://github.com/open-feature/dotnet-sdk/compare/v2.1.0...v2.2.0) (2024-12-12) + + +### โœจ New Features + +* Feature Provider Enhancements- [#321](https://github.com/open-feature/dotnet-sdk/issues/321) ([#324](https://github.com/open-feature/dotnet-sdk/issues/324)) ([70f847b](https://github.com/open-feature/dotnet-sdk/commit/70f847b2979e9b2b69f4e560799e2bc9fe87d5e8)) +* Implement Tracking in .NET [#309](https://github.com/open-feature/dotnet-sdk/issues/309) ([#327](https://github.com/open-feature/dotnet-sdk/issues/327)) ([cbf4f25](https://github.com/open-feature/dotnet-sdk/commit/cbf4f25a4365eac15e37987d2d7163cb1aefacfe)) +* Support Returning Error Resolutions from Providers ([#323](https://github.com/open-feature/dotnet-sdk/issues/323)) ([bf9de4e](https://github.com/open-feature/dotnet-sdk/commit/bf9de4e177a4963340278854a25dd355f95dfc51)) + + +### ๐Ÿงน Chore + +* **deps:** update dependency fluentassertions to v7 ([#325](https://github.com/open-feature/dotnet-sdk/issues/325)) ([35cd77b](https://github.com/open-feature/dotnet-sdk/commit/35cd77b59dc938301e7e22ddefd9b39ef8e21a4b)) + +## [2.1.0](https://github.com/open-feature/dotnet-sdk/compare/v2.0.0...v2.1.0) (2024-11-18) + + +### ๐Ÿ› Bug Fixes + +* Fix action syntax in workflow configuration ([#315](https://github.com/open-feature/dotnet-sdk/issues/315)) ([ccf0250](https://github.com/open-feature/dotnet-sdk/commit/ccf02506ecd924738b6ae03dedf25c8e2df6d1fb)) +* Fix unit test clean context ([#313](https://github.com/open-feature/dotnet-sdk/issues/313)) ([3038142](https://github.com/open-feature/dotnet-sdk/commit/30381423333c54e1df98d7721dd72697fc5406dc)) + + +### โœจ New Features + +* Add Dependency Injection and Hosting support for OpenFeature ([#310](https://github.com/open-feature/dotnet-sdk/issues/310)) ([1aaa0ec](https://github.com/open-feature/dotnet-sdk/commit/1aaa0ec0e75d5048554752db30193694f0999a4a)) + + +### ๐Ÿงน Chore + +* **deps:** update actions/upload-artifact action to v4.4.3 ([#292](https://github.com/open-feature/dotnet-sdk/issues/292)) ([9b693f7](https://github.com/open-feature/dotnet-sdk/commit/9b693f737f111ed878749f725dd4c831206b308a)) +* **deps:** update codecov/codecov-action action to v4.6.0 ([#306](https://github.com/open-feature/dotnet-sdk/issues/306)) ([4b92528](https://github.com/open-feature/dotnet-sdk/commit/4b92528bd56541ca3701bd4cf80467cdda80f046)) +* **deps:** update dependency dotnet-sdk to v8.0.401 ([#296](https://github.com/open-feature/dotnet-sdk/issues/296)) ([0bae29d](https://github.com/open-feature/dotnet-sdk/commit/0bae29d4771c4901e0c511b8d3587e6501e67ecd)) +* **deps:** update dependency fluentassertions to 6.12.2 ([#302](https://github.com/open-feature/dotnet-sdk/issues/302)) ([bc7e187](https://github.com/open-feature/dotnet-sdk/commit/bc7e187b7586a04e0feb9ef28291ce14c9ac35c5)) +* **deps:** update dependency microsoft.net.test.sdk to 17.11.0 ([#297](https://github.com/open-feature/dotnet-sdk/issues/297)) ([5593e19](https://github.com/open-feature/dotnet-sdk/commit/5593e19ca990196f754cd0be69391abb8f0dbcd5)) +* **deps:** update dependency microsoft.net.test.sdk to 17.11.1 ([#301](https://github.com/open-feature/dotnet-sdk/issues/301)) ([5b979d2](https://github.com/open-feature/dotnet-sdk/commit/5b979d290d96020ffe7f3e5729550d6f988b2af2)) +* **deps:** update dependency nsubstitute to 5.3.0 ([#311](https://github.com/open-feature/dotnet-sdk/issues/311)) ([87f9cfa](https://github.com/open-feature/dotnet-sdk/commit/87f9cfa9b5ace84546690fea95f33bf06fd1947b)) +* **deps:** update dependency xunit to 2.9.2 ([#303](https://github.com/open-feature/dotnet-sdk/issues/303)) ([2273948](https://github.com/open-feature/dotnet-sdk/commit/22739486ee107562c72d02a46190c651e59a753c)) +* **deps:** update dotnet monorepo ([#305](https://github.com/open-feature/dotnet-sdk/issues/305)) ([3955b16](https://github.com/open-feature/dotnet-sdk/commit/3955b1604d5dad9b67e01974d96d53d5cacb9aad)) +* **deps:** update dotnet monorepo to 8.0.2 ([#319](https://github.com/open-feature/dotnet-sdk/issues/319)) ([94681f3](https://github.com/open-feature/dotnet-sdk/commit/94681f37821cc44388f0cd8898924cbfbcda0cd3)) +* update release please config ([#304](https://github.com/open-feature/dotnet-sdk/issues/304)) ([c471c06](https://github.com/open-feature/dotnet-sdk/commit/c471c062cf70d78b67f597f468c62dbfbf0674d2)) + +## [2.0.0](https://github.com/open-feature/dotnet-sdk/compare/v1.5.0...v2.0.0) (2024-08-21) + +Today we're announcing the release of the OpenFeature SDK for .NET, v2.0! This release contains several ergonomic improvements to the SDK, which .NET developers will appreciate. It also includes some performance optimizations brought to you by the latest .NET primitives. + +For details and migration tips, check out: https://openfeature.dev/blog/dotnet-sdk-v2 + +### โš  BREAKING CHANGES + +* domain instead of client name ([#294](https://github.com/open-feature/dotnet-sdk/issues/294)) +* internally maintain provider status ([#276](https://github.com/open-feature/dotnet-sdk/issues/276)) +* add CancellationTokens, ValueTasks hooks ([#268](https://github.com/open-feature/dotnet-sdk/issues/268)) +* Use same type for flag metadata and event metadata ([#241](https://github.com/open-feature/dotnet-sdk/issues/241)) +* Enable nullable reference types ([#253](https://github.com/open-feature/dotnet-sdk/issues/253)) + +### ๐Ÿ› Bug Fixes + +* Add missing error message when an error occurred ([#256](https://github.com/open-feature/dotnet-sdk/issues/256)) ([949d53c](https://github.com/open-feature/dotnet-sdk/commit/949d53cada68bee8e80d113357fa6df8d425d3c1)) +* Should map metadata when converting from ResolutionDetails to FlagEvaluationDetails ([#282](https://github.com/open-feature/dotnet-sdk/issues/282)) ([2f8bd21](https://github.com/open-feature/dotnet-sdk/commit/2f8bd2179ec35f79cbbab77206de78dd9b0f58d6)) + + +### โœจ New Features + +* add CancellationTokens, ValueTasks hooks ([#268](https://github.com/open-feature/dotnet-sdk/issues/268)) ([33154d2](https://github.com/open-feature/dotnet-sdk/commit/33154d2ed6b0b27f4a86a5fbad440a784a89c881)) +* back targetingKey with internal map ([#287](https://github.com/open-feature/dotnet-sdk/issues/287)) ([ccc2f7f](https://github.com/open-feature/dotnet-sdk/commit/ccc2f7fbd4e4f67eb03c2e6a07140ca31225da2c)) +* domain instead of client name ([#294](https://github.com/open-feature/dotnet-sdk/issues/294)) ([4c0592e](https://github.com/open-feature/dotnet-sdk/commit/4c0592e6baf86d831fc7b39762c960ca0dd843a9)) +* Drop net7 TFM ([#284](https://github.com/open-feature/dotnet-sdk/issues/284)) ([2dbe1f4](https://github.com/open-feature/dotnet-sdk/commit/2dbe1f4c95aeae501c8b5154b1ccefafa7df2632)) +* internally maintain provider status ([#276](https://github.com/open-feature/dotnet-sdk/issues/276)) ([63faa84](https://github.com/open-feature/dotnet-sdk/commit/63faa8440cd650b0bd6c3ec009ad9bd78bc31f32)) +* Use same type for flag metadata and event metadata ([#241](https://github.com/open-feature/dotnet-sdk/issues/241)) ([ac7d7de](https://github.com/open-feature/dotnet-sdk/commit/ac7d7debf50cef08668bcd9457d3f830b8718806)) + + +### ๐Ÿงน Chore + +* cleanup code ([#277](https://github.com/open-feature/dotnet-sdk/issues/277)) ([44cf586](https://github.com/open-feature/dotnet-sdk/commit/44cf586f96607716fb8b4464d81edfd6074f7376)) +* **deps:** Project file cleanup and remove unnecessary dependencies ([#251](https://github.com/open-feature/dotnet-sdk/issues/251)) ([79def47](https://github.com/open-feature/dotnet-sdk/commit/79def47106b19b316b691fa195f7160ddcfb9a41)) +* **deps:** update actions/upload-artifact action to v4.3.3 ([#263](https://github.com/open-feature/dotnet-sdk/issues/263)) ([7718649](https://github.com/open-feature/dotnet-sdk/commit/77186495cd3d567b0aabd418f23a65567656b54d)) +* **deps:** update actions/upload-artifact action to v4.3.4 ([#278](https://github.com/open-feature/dotnet-sdk/issues/278)) ([15189f1](https://github.com/open-feature/dotnet-sdk/commit/15189f1c6f7eb0931036e022eed68f58a1110b5b)) +* **deps:** update actions/upload-artifact action to v4.3.5 ([#291](https://github.com/open-feature/dotnet-sdk/issues/291)) ([00e99d6](https://github.com/open-feature/dotnet-sdk/commit/00e99d6c2208b304748d00a931f460d6d6aab4de)) +* **deps:** update codecov/codecov-action action to v4 ([#227](https://github.com/open-feature/dotnet-sdk/issues/227)) ([11a0333](https://github.com/open-feature/dotnet-sdk/commit/11a03332726f07dd0327d222e6bd6e1843db460c)) +* **deps:** update codecov/codecov-action action to v4.3.1 ([#267](https://github.com/open-feature/dotnet-sdk/issues/267)) ([ff9df59](https://github.com/open-feature/dotnet-sdk/commit/ff9df593400f92c016eee1a45bd7097da008d4dc)) +* **deps:** update codecov/codecov-action action to v4.5.0 ([#272](https://github.com/open-feature/dotnet-sdk/issues/272)) ([281295d](https://github.com/open-feature/dotnet-sdk/commit/281295d2999e4d36c5a2078cbfdfe5e59f4652b2)) +* **deps:** update dependency benchmarkdotnet to v0.14.0 ([#293](https://github.com/open-feature/dotnet-sdk/issues/293)) ([aec222f](https://github.com/open-feature/dotnet-sdk/commit/aec222fe1b1a5b52f8349ceb98c12b636eb155eb)) +* **deps:** update dependency coverlet.collector to v6.0.2 ([#247](https://github.com/open-feature/dotnet-sdk/issues/247)) ([ab34c16](https://github.com/open-feature/dotnet-sdk/commit/ab34c16b513ddbd0a53e925baaccd088163fbcc8)) +* **deps:** update dependency coverlet.msbuild to v6.0.2 ([#239](https://github.com/open-feature/dotnet-sdk/issues/239)) ([e654222](https://github.com/open-feature/dotnet-sdk/commit/e6542222827cc25cd5a1acc5af47ce55149c0623)) +* **deps:** update dependency dotnet-sdk to v8.0.204 ([#261](https://github.com/open-feature/dotnet-sdk/issues/261)) ([8f82645](https://github.com/open-feature/dotnet-sdk/commit/8f8264520814a42b7ed2af8f70340e7673259b6f)) +* **deps:** update dependency dotnet-sdk to v8.0.301 ([#271](https://github.com/open-feature/dotnet-sdk/issues/271)) ([acd0385](https://github.com/open-feature/dotnet-sdk/commit/acd0385641e114a16d0ee56e3a143baa7d3c0535)) +* **deps:** update dependency dotnet-sdk to v8.0.303 ([#275](https://github.com/open-feature/dotnet-sdk/issues/275)) ([871dcac](https://github.com/open-feature/dotnet-sdk/commit/871dcacc94fa2abb10434616c469cad6f674f07a)) +* **deps:** update dependency dotnet-sdk to v8.0.400 ([#295](https://github.com/open-feature/dotnet-sdk/issues/295)) ([bb4f352](https://github.com/open-feature/dotnet-sdk/commit/bb4f3526c2c2c2ca48ae61e883d6962847ebc5a6)) +* **deps:** update dependency githubactionstestlogger to v2.4.1 ([#274](https://github.com/open-feature/dotnet-sdk/issues/274)) ([46c2b15](https://github.com/open-feature/dotnet-sdk/commit/46c2b153c848bd3a500b828ddb89bd3b07753bf1)) +* **deps:** update dependency microsoft.net.test.sdk to v17.10.0 ([#273](https://github.com/open-feature/dotnet-sdk/issues/273)) ([581ff81](https://github.com/open-feature/dotnet-sdk/commit/581ff81c7b1840c34840229bf20444c528c64cc6)) +* **deps:** update dotnet monorepo ([#218](https://github.com/open-feature/dotnet-sdk/issues/218)) ([bc8301d](https://github.com/open-feature/dotnet-sdk/commit/bc8301d1c54e0b48ede3235877d969f28d61fb29)) +* **deps:** update xunit-dotnet monorepo ([#262](https://github.com/open-feature/dotnet-sdk/issues/262)) ([43f14cc](https://github.com/open-feature/dotnet-sdk/commit/43f14cca072372ecacec89a949c85f763c1ee7b4)) +* **deps:** update xunit-dotnet monorepo ([#279](https://github.com/open-feature/dotnet-sdk/issues/279)) ([fb1cc66](https://github.com/open-feature/dotnet-sdk/commit/fb1cc66440dd6bdbbef1ac1f85bf3228b80073af)) +* **deps:** update xunit-dotnet monorepo to v2.8.1 ([#266](https://github.com/open-feature/dotnet-sdk/issues/266)) ([a7b6d85](https://github.com/open-feature/dotnet-sdk/commit/a7b6d8561716763f324325a8803b913c4d69c044)) +* Enable nullable reference types ([#253](https://github.com/open-feature/dotnet-sdk/issues/253)) ([5a5312c](https://github.com/open-feature/dotnet-sdk/commit/5a5312cc082ccd880b65165135e05b4f3b035df7)) +* in-memory UpdateFlags to UpdateFlagsAsync ([#298](https://github.com/open-feature/dotnet-sdk/issues/298)) ([390205a](https://github.com/open-feature/dotnet-sdk/commit/390205a41d29d786b5f41b0d91f34ec237276cb4)) +* prompt 2.0 ([9b9c3fd](https://github.com/open-feature/dotnet-sdk/commit/9b9c3fd09c27b191104d7ceaa726b6edd71fcd06)) +* Support for determining spec support for the repo ([#270](https://github.com/open-feature/dotnet-sdk/issues/270)) ([67a1a0a](https://github.com/open-feature/dotnet-sdk/commit/67a1a0aea95ee943976990b1d1782e4061300b50)) + +## [1.5.0](https://github.com/open-feature/dotnet-sdk/compare/v1.4.1...v1.5.0) (2024-03-12) + + +### ๐Ÿ› Bug Fixes + +* Add targeting key ([#231](https://github.com/open-feature/dotnet-sdk/issues/231)) ([d792b32](https://github.com/open-feature/dotnet-sdk/commit/d792b32c567b3c4ecded3fb8aab7ad9832048dcc)) +* Fix NU1009 reference assembly warning ([#222](https://github.com/open-feature/dotnet-sdk/issues/222)) ([7eebcdd](https://github.com/open-feature/dotnet-sdk/commit/7eebcdda123f9a432a8462d918b7454a26d3e389)) +* invalid editorconfig ([#244](https://github.com/open-feature/dotnet-sdk/issues/244)) ([3c00757](https://github.com/open-feature/dotnet-sdk/commit/3c0075738c07e0bb2bc9875be9037f7ccbf90ac5)) + + +### โœจ New Features + +* Flag metadata ([#223](https://github.com/open-feature/dotnet-sdk/issues/223)) ([fd0a541](https://github.com/open-feature/dotnet-sdk/commit/fd0a54110866f3245152b28b64dedd286a752f64)) +* implement in-memory provider ([#232](https://github.com/open-feature/dotnet-sdk/issues/232)) ([1082094](https://github.com/open-feature/dotnet-sdk/commit/10820947f3d1ad0f710bccf5990b7c993956ff51)) + + +### ๐Ÿงน Chore + +* bump spec version badge ([#246](https://github.com/open-feature/dotnet-sdk/issues/246)) ([ebf5552](https://github.com/open-feature/dotnet-sdk/commit/ebf55522146dad0432792bdc8cdf8772aae7d627)) +* cleanup unused usings ๐Ÿงน ([#240](https://github.com/open-feature/dotnet-sdk/issues/240)) ([cdc1bee](https://github.com/open-feature/dotnet-sdk/commit/cdc1beeb00b50d47658b5fa9f053385afa227a94)) +* **deps:** update actions/upload-artifact action to v4.3.0 ([#203](https://github.com/open-feature/dotnet-sdk/issues/203)) ([0a7e98d](https://github.com/open-feature/dotnet-sdk/commit/0a7e98daf7d5f66f5aa8d97146e8444aa2685a33)) +* **deps:** update actions/upload-artifact action to v4.3.1 ([#233](https://github.com/open-feature/dotnet-sdk/issues/233)) ([cfaf1c8](https://github.com/open-feature/dotnet-sdk/commit/cfaf1c8350a1d6754e2cfadc5daaddf2a40524e9)) +* **deps:** update codecov/codecov-action action to v3.1.5 ([#209](https://github.com/open-feature/dotnet-sdk/issues/209)) ([a509b1f](https://github.com/open-feature/dotnet-sdk/commit/a509b1fb1d360ea0ac25e515ef5c7827996d4b4e)) +* **deps:** update codecov/codecov-action action to v3.1.6 ([#226](https://github.com/open-feature/dotnet-sdk/issues/226)) ([a577a80](https://github.com/open-feature/dotnet-sdk/commit/a577a80fc9b93fa5ddced6452da1e74f3bf9afc7)) +* **deps:** update dependency coverlet.collector to v6.0.1 ([#238](https://github.com/open-feature/dotnet-sdk/issues/238)) ([f2cb67b](https://github.com/open-feature/dotnet-sdk/commit/f2cb67bf40b96981f76da31242c591aeb1a2d2f5)) +* **deps:** update dependency fluentassertions to v6.12.0 ([#215](https://github.com/open-feature/dotnet-sdk/issues/215)) ([2c237df](https://github.com/open-feature/dotnet-sdk/commit/2c237df6e0ad278ddd8a51add202b797bf81374e)) +* **deps:** update dependency microsoft.net.test.sdk to v17.8.0 ([#216](https://github.com/open-feature/dotnet-sdk/issues/216)) ([4cb3ae0](https://github.com/open-feature/dotnet-sdk/commit/4cb3ae09375ad5f172b2e0673c9c30678939e9fd)) +* **deps:** update dependency nsubstitute to v5.1.0 ([#217](https://github.com/open-feature/dotnet-sdk/issues/217)) ([3be76cd](https://github.com/open-feature/dotnet-sdk/commit/3be76cd562bbe942070e3c532edf40694e098440)) +* **deps:** update dependency openfeature.contrib.providers.flagd to v0.1.8 ([#211](https://github.com/open-feature/dotnet-sdk/issues/211)) ([c1aece3](https://github.com/open-feature/dotnet-sdk/commit/c1aece35c34e40ec911622e89882527d6815d267)) +* **deps:** update xunit-dotnet monorepo ([#236](https://github.com/open-feature/dotnet-sdk/issues/236)) ([fa25ece](https://github.com/open-feature/dotnet-sdk/commit/fa25ece0444c04e2c0a12fca21064920bc09159a)) +* Enable Central Package Management (CPM) ([#178](https://github.com/open-feature/dotnet-sdk/issues/178)) ([249a0a8](https://github.com/open-feature/dotnet-sdk/commit/249a0a8b35d0205117153e8f32948d65b7754b44)) +* Enforce coding styles on build ([#242](https://github.com/open-feature/dotnet-sdk/issues/242)) ([64699c8](https://github.com/open-feature/dotnet-sdk/commit/64699c8c0b5598b71fa94041797bc98d3afc8863)) +* More sln cleanup ([#206](https://github.com/open-feature/dotnet-sdk/issues/206)) ([bac3d94](https://github.com/open-feature/dotnet-sdk/commit/bac3d9483817a330044c8a13a4b3e1ffa296e009)) +* SourceLink is built-in for .NET SDK 8.0.100+ ([#198](https://github.com/open-feature/dotnet-sdk/issues/198)) ([45e2c86](https://github.com/open-feature/dotnet-sdk/commit/45e2c862fd96092c3d20ddc5dfba46febfe802c8)) +* Sync ci.yml with contrib repo ([#196](https://github.com/open-feature/dotnet-sdk/issues/196)) ([130654b](https://github.com/open-feature/dotnet-sdk/commit/130654b9ae97a20c6d8964a9c0c0e0188209db55)) +* Sync release.yml with ci.yml following [#173](https://github.com/open-feature/dotnet-sdk/issues/173) ([#195](https://github.com/open-feature/dotnet-sdk/issues/195)) ([eba8848](https://github.com/open-feature/dotnet-sdk/commit/eba8848cb61f28b64f4a021f1534d300fcddf4eb)) + + +### ๐Ÿ“š Documentation + +* fix hook ecosystem link ([#229](https://github.com/open-feature/dotnet-sdk/issues/229)) ([cc6c404](https://github.com/open-feature/dotnet-sdk/commit/cc6c404504d9db1c234cf5642ee0c5595868774f)) +* update the feature table key ([f8724cd](https://github.com/open-feature/dotnet-sdk/commit/f8724cd625a1f9edb33cd208aac70db3766593f1)) + +## [1.4.1](https://github.com/open-feature/dotnet-sdk/compare/v1.4.0...v1.4.1) (2024-01-23) + + +### ๐Ÿ“š Documentation + +* add release please tag twice ([b34fe78](https://github.com/open-feature/dotnet-sdk/commit/b34fe78636dfb6b2c334a68c44ae57b72b04acbc)) +* add release please version range ([#201](https://github.com/open-feature/dotnet-sdk/issues/201)) ([aa35e25](https://github.com/open-feature/dotnet-sdk/commit/aa35e253b58755b4e0b75a4b272e5a368cb5566c)) +* fix release please tag ([8b1c9d2](https://github.com/open-feature/dotnet-sdk/commit/8b1c9d2cc26c086dcdb2434ba8646ffc07c3d063)) +* update readme to be pure markdown ([#199](https://github.com/open-feature/dotnet-sdk/issues/199)) ([33db9d9](https://github.com/open-feature/dotnet-sdk/commit/33db9d925478f8249e9fea7465998c8ec8da686b)) +* update release please tags ([8dcb824](https://github.com/open-feature/dotnet-sdk/commit/8dcb824e2b39e14e3b7345cd0d89f7660ca798cd)) + +## [1.4.0](https://github.com/open-feature/dotnet-sdk/compare/v1.3.1...v1.4.0) (2024-01-23) + + +### ๐Ÿ› Bug Fixes + +* Fix ArgumentOutOfRangeException for empty hooks ([#187](https://github.com/open-feature/dotnet-sdk/issues/187)) ([950775b](https://github.com/open-feature/dotnet-sdk/commit/950775b65093e22ce209c947bf9da71b17ad7387)) +* More robust shutdown/cleanup/reset ([#188](https://github.com/open-feature/dotnet-sdk/issues/188)) ([a790f78](https://github.com/open-feature/dotnet-sdk/commit/a790f78c32b8500ce27d04d906c98d1de2afd2b4)) +* Remove upper-bound version constraint from SCI ([#171](https://github.com/open-feature/dotnet-sdk/issues/171)) ([8f8b661](https://github.com/open-feature/dotnet-sdk/commit/8f8b661f1cac6a4f1c51eb513999372d30a4f726)) + + +### โœจ New Features + +* Add dx to catch ConfigureAwait(false) ([#152](https://github.com/open-feature/dotnet-sdk/issues/152)) ([9c42d4a](https://github.com/open-feature/dotnet-sdk/commit/9c42d4afa9139094e0316bbe1306ae4856b7d013)) +* add support for eventing ([#166](https://github.com/open-feature/dotnet-sdk/issues/166)) ([f5fc1dd](https://github.com/open-feature/dotnet-sdk/commit/f5fc1ddadc11f712ae0893cde815e7a1c6fe2c1b)) +* Add support for provider shutdown and status. ([#158](https://github.com/open-feature/dotnet-sdk/issues/158)) ([24c3441](https://github.com/open-feature/dotnet-sdk/commit/24c344163423973b54a06b73648ba45b944589ee)) + + +### ๐Ÿงน Chore + +* Add GitHub Actions logger for CI ([#174](https://github.com/open-feature/dotnet-sdk/issues/174)) ([c1a189a](https://github.com/open-feature/dotnet-sdk/commit/c1a189a5cff7106d37f0a45dd5824f18e7ec0cd6)) +* add placeholder eventing and shutdown sections ([#156](https://github.com/open-feature/dotnet-sdk/issues/156)) ([5dfea29](https://github.com/open-feature/dotnet-sdk/commit/5dfea29bb3d01f6c8640de321c4fde52f283a1c0)) +* Add support for GitHub Packages ([#173](https://github.com/open-feature/dotnet-sdk/issues/173)) ([26cd5cd](https://github.com/open-feature/dotnet-sdk/commit/26cd5cdd613577c53ae79b889d1cf2d89262236f)) +* Adding sealed keyword to classes ([#191](https://github.com/open-feature/dotnet-sdk/issues/191)) ([1a14f6c](https://github.com/open-feature/dotnet-sdk/commit/1a14f6cd6c8988756a2cf2da1137a739e8d960f8)) +* **deps:** update actions/checkout action to v4 ([#144](https://github.com/open-feature/dotnet-sdk/issues/144)) ([90d9d02](https://github.com/open-feature/dotnet-sdk/commit/90d9d021b227fba626bb99454cb7c0f7fef2d8d8)) +* **deps:** update actions/setup-dotnet action to v4 ([#162](https://github.com/open-feature/dotnet-sdk/issues/162)) ([0b0bb10](https://github.com/open-feature/dotnet-sdk/commit/0b0bb10419f836d9cc276fe8ac3c71c9214420ef)) +* **deps:** update dependency dotnet-sdk to v7.0.404 ([#148](https://github.com/open-feature/dotnet-sdk/issues/148)) ([e8ca1da](https://github.com/open-feature/dotnet-sdk/commit/e8ca1da9ed63df9685ec49a9569e0ec99ba0b3b9)) +* **deps:** update github/codeql-action action to v3 ([#163](https://github.com/open-feature/dotnet-sdk/issues/163)) ([c85e93e](https://github.com/open-feature/dotnet-sdk/commit/c85e93e9c9a97083660f9062c38dcbf6d64a3ad6)) +* fix alt text for NuGet on the readme ([2cbdba8](https://github.com/open-feature/dotnet-sdk/commit/2cbdba80d836f8b7850e8dc5f1f1790ef2ed1aca)) +* Fix FieldCanBeMadeReadOnly ([#183](https://github.com/open-feature/dotnet-sdk/issues/183)) ([18a092a](https://github.com/open-feature/dotnet-sdk/commit/18a092afcab1b06c25f3b825a6130d22226790fc)) +* Fix props to support more than one project ([#177](https://github.com/open-feature/dotnet-sdk/issues/177)) ([f47cf07](https://github.com/open-feature/dotnet-sdk/commit/f47cf07420cdcb6bc74b0455898b7b17a144daf3)) +* minor formatting cleanup ([#168](https://github.com/open-feature/dotnet-sdk/issues/168)) ([d0c25af](https://github.com/open-feature/dotnet-sdk/commit/d0c25af7df5176d10088c148eac35b0034536e04)) +* Reduce dependency on MEL -> MELA ([#176](https://github.com/open-feature/dotnet-sdk/issues/176)) ([a6062fe](https://github.com/open-feature/dotnet-sdk/commit/a6062fe2b9f0d83490c7ce900e837863521f5f55)) +* remove duplicate eventing section in readme ([1efe09d](https://github.com/open-feature/dotnet-sdk/commit/1efe09da3948d5dfd7fd9f1c7a040fc5c2cbe833)) +* remove test sleeps, fix flaky test ([#194](https://github.com/open-feature/dotnet-sdk/issues/194)) ([f2b9b03](https://github.com/open-feature/dotnet-sdk/commit/f2b9b03eda5f6d6b4a738f761702cd9d9a105e76)) +* revert breaking setProvider ([#190](https://github.com/open-feature/dotnet-sdk/issues/190)) ([2919c2f](https://github.com/open-feature/dotnet-sdk/commit/2919c2f4f2a4629fccd1a50b1885375006445b96)) +* update spec release link ([a2f70eb](https://github.com/open-feature/dotnet-sdk/commit/a2f70ebd68357156f9045fc6e94845a53ffd204a)) +* updated readme for inclusion in the docs ([6516866](https://github.com/open-feature/dotnet-sdk/commit/6516866ec7601a7adaa4dc6b517c9287dec54fca)) + + +### ๐Ÿ“š Documentation + +* Add README.md to the nuget package ([#164](https://github.com/open-feature/dotnet-sdk/issues/164)) ([b6b0ee2](https://github.com/open-feature/dotnet-sdk/commit/b6b0ee2b61a9b0b973b913b53887badfa0c5a3de)) +* fixed the contrib url on the readme ([9d8939e](https://github.com/open-feature/dotnet-sdk/commit/9d8939ef57a3be4ee220bd21f36b166887b2c30b)) +* remove duplicate a tag from readme ([2687cf0](https://github.com/open-feature/dotnet-sdk/commit/2687cf0663e20aa2dd113569cbf177833639cbbd)) +* update README.md ([#155](https://github.com/open-feature/dotnet-sdk/issues/155)) ([b62e21f](https://github.com/open-feature/dotnet-sdk/commit/b62e21f76964e7f6f7456f720814de0997232d71)) + + +### ๐Ÿ”„ Refactoring + +* Add TFMs for net{6,7,8}.0 ([#172](https://github.com/open-feature/dotnet-sdk/issues/172)) ([cf2baa8](https://github.com/open-feature/dotnet-sdk/commit/cf2baa8a6b4328f1aa346bbea91160aa2e5f3a8d)) + +## [1.3.1](https://github.com/open-feature/dotnet-sdk/compare/v1.3.0...v1.3.1) (2023-09-19) + + +### ๐Ÿ› Bug Fixes + +* deadlocks in client applications ([#150](https://github.com/open-feature/dotnet-sdk/issues/150)) ([17a7772](https://github.com/open-feature/dotnet-sdk/commit/17a7772c0dad9c68a4a0e0e272fe32ce3bfe0cff)) + + +### ๐Ÿงน Chore + +* **deps:** update dependency dotnet-sdk to v7.0.306 ([#135](https://github.com/open-feature/dotnet-sdk/issues/135)) ([15473b6](https://github.com/open-feature/dotnet-sdk/commit/15473b6c3ab969ca660b7f3a98e1999373517b42)) +* **deps:** update dependency dotnet-sdk to v7.0.400 ([#139](https://github.com/open-feature/dotnet-sdk/issues/139)) ([ecc9707](https://github.com/open-feature/dotnet-sdk/commit/ecc970701ff46815d0116417232f7c6ea670bdef)) +* update rp config (emoji) ([f921dc6](https://github.com/open-feature/dotnet-sdk/commit/f921dc699a358070568be93027680d49e0f7cb8e)) + + +### ๐Ÿ“š Documentation + +* Update README.md ([#147](https://github.com/open-feature/dotnet-sdk/issues/147)) ([3da02e6](https://github.com/open-feature/dotnet-sdk/commit/3da02e67a6e1e11af72fbd38aa42215b41b4e33b)) + +## [1.3.0](https://github.com/open-feature/dotnet-sdk/compare/v1.2.0...v1.3.0) (2023-07-14) + + +### Features + +* Support for name client to given provider ([#129](https://github.com/open-feature/dotnet-sdk/issues/129)) ([3f765c6](https://github.com/open-feature/dotnet-sdk/commit/3f765c6fb4ccd651de2d4f46e1fec38cd26610fe)) + + +### Bug Fixes + +* max System.Collections.Immutable version ++ ([#137](https://github.com/open-feature/dotnet-sdk/issues/137)) ([55c5e8e](https://github.com/open-feature/dotnet-sdk/commit/55c5e8e5c9e7667afb84d0b7946234e5274d4924)) + +## [1.2.0](https://github.com/open-feature/dotnet-sdk/compare/v1.1.0...v1.2.0) (2023-02-14) + + +### Features + +* split errors to classes by types ([#115](https://github.com/open-feature/dotnet-sdk/issues/115)) ([5f348f4](https://github.com/open-feature/dotnet-sdk/commit/5f348f46f2d9a5578a0db951bd78508ab74cabc0)) + +## [1.1.0](https://github.com/open-feature/dotnet-sdk/compare/v1.0.1...v1.1.0) (2023-01-18) + + +### Features + +* add STATIC, CACHED reasons ([#101](https://github.com/open-feature/dotnet-sdk/issues/101)) ([7cc7ab4](https://github.com/open-feature/dotnet-sdk/commit/7cc7ab46fc20a97c9f4398f6d1fe80e43db514e1)) +* include net7 in the test suit ([#97](https://github.com/open-feature/dotnet-sdk/issues/97)) ([594d5f2](https://github.com/open-feature/dotnet-sdk/commit/594d5f21f735473bf8585f9f6de67d758b1bf12c)) +* Make IFeatureClient interface public. ([#102](https://github.com/open-feature/dotnet-sdk/issues/102)) ([5a09c4f](https://github.com/open-feature/dotnet-sdk/commit/5a09c4f38c15b47b6e1aa62a57ea4f49c08fab77)) + +## [1.0.1](https://github.com/open-feature/dotnet-sdk/compare/v1.0.0...v1.0.1) (2022-10-28) + + +### Bug Fixes + +* correct version range on logging ([#89](https://github.com/open-feature/dotnet-sdk/issues/89)) ([9443239](https://github.com/open-feature/dotnet-sdk/commit/9443239adeb3144c6f683faf400dddf5ac493628)) + +## [1.0.0](https://github.com/open-feature/dotnet-sdk/compare/v0.5.0...v1.0.0) (2022-10-21) + + +### Miscellaneous Chores + +* release 1.0.0 ([#85](https://github.com/open-feature/dotnet-sdk/issues/85)) ([79c0d8d](https://github.com/open-feature/dotnet-sdk/commit/79c0d8d0aa07f7aa69023de3437c3774df507e53)) + +## [0.5.0](https://github.com/open-feature/dotnet-sdk/compare/v0.4.0...v0.5.0) (2022-10-16) + + +### โš  BREAKING CHANGES + +* rename OpenFeature class to API and ns to OpenFeature (#82) + +### Features + +* rename OpenFeature class to API and ns to OpenFeature ([#82](https://github.com/open-feature/dotnet-sdk/issues/82)) ([6090bd9](https://github.com/open-feature/dotnet-sdk/commit/6090bd971817cc6cc8b74487b2850d8e99a2c94d)) + +## [0.4.0](https://github.com/open-feature/dotnet-sdk/compare/v0.3.0...v0.4.0) (2022-10-12) + + +### โš  BREAKING CHANGES + +* Thread safe hooks, provider, and context (#79) +* Implement builders and immutable contexts. (#77) + +### Features + +* Implement builders and immutable contexts. ([#77](https://github.com/open-feature/dotnet-sdk/issues/77)) ([d980a94](https://github.com/open-feature/dotnet-sdk/commit/d980a94402bdb94cae4c60c1809f1579be7f5449)) +* Thread safe hooks, provider, and context ([#79](https://github.com/open-feature/dotnet-sdk/issues/79)) ([609016f](https://github.com/open-feature/dotnet-sdk/commit/609016fc86f8eee8d848a9227b57aaef0d9b85b0)) + +## [0.3.0](https://github.com/open-feature/dotnet-sdk/compare/v0.2.3...v0.3.0) (2022-09-28) + + +### โš  BREAKING CHANGES + +* ErrorType as enum, add ErrorMessage string (#72) + +### Features + +* ErrorType as enum, add ErrorMessage string ([#72](https://github.com/open-feature/dotnet-sdk/issues/72)) ([e7ab498](https://github.com/open-feature/dotnet-sdk/commit/e7ab49866bd83d7b146059b0c22944a7db6956b4)) + +## [0.2.3](https://github.com/open-feature/dotnet-sdk/compare/v0.2.2...v0.2.3) (2022-09-22) + + +### Bug Fixes + +* add dir to publish ([#69](https://github.com/open-feature/dotnet-sdk/issues/69)) ([6549dbb](https://github.com/open-feature/dotnet-sdk/commit/6549dbb4f3a525a70cebdc9a63661ce6eaba9266)) + +## [0.2.2](https://github.com/open-feature/dotnet-sdk/compare/v0.2.1...v0.2.2) (2022-09-22) + + +### Bug Fixes + +* change NUGET_API_KEY -> NUGET_TOKEN ([#67](https://github.com/open-feature/dotnet-sdk/issues/67)) ([87c99b2](https://github.com/open-feature/dotnet-sdk/commit/87c99b2128d50d72b54cb27e2f866f1edb0cd0d3)) + +## [0.2.1](https://github.com/open-feature/dotnet-sdk/compare/v0.2.0...v0.2.1) (2022-09-22) + + +### Bug Fixes + +* substitute version number into filename when pushing package ([#65](https://github.com/open-feature/dotnet-sdk/issues/65)) ([8c8500c](https://github.com/open-feature/dotnet-sdk/commit/8c8500c71edb84c256b177c40815a34607adb682)) + +## [0.2.0](https://github.com/open-feature/dotnet-sdk/compare/v0.1.5...v0.2.0) (2022-09-22) + + +### โš  BREAKING CHANGES + +* use correct path to extra file (#63) +* Rename namespace from OpenFeature.SDK to OpenFeatureSDK (#62) + +### Bug Fixes + +* Rename namespace from OpenFeature.SDK to OpenFeatureSDK ([#62](https://github.com/open-feature/dotnet-sdk/issues/62)) ([430ffc0](https://github.com/open-feature/dotnet-sdk/commit/430ffc0a3afc871772286241d39a613c91298da5)) +* use correct path to extra file ([#63](https://github.com/open-feature/dotnet-sdk/issues/63)) ([ee39839](https://github.com/open-feature/dotnet-sdk/commit/ee398399d9371517c4b03b55a93619776ecd3a92)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..98800faf --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,192 @@ +# Contributing to the OpenFeature project + +## Development + +You can contribute to this project from a Windows, macOS or Linux machine. + +On all platforms, the minimum requirements are: + +- Git client and command line tools. +- .netstandard 2.0 or higher capable dotnet sdk (.Net Framework 4.6.2 or higher/.Net 8 or higher). + +### Linux or MacOS + +- JetBrains Rider 2022.2+ or Visual Studio 2022+ for Mac or Visual Studio Code + +### Windows + +- JetBrains Rider 2022.2+ or Visual Studio 2022+ or Visual Studio Code +- .NET Framework 4.6.2+ + +## Pull Request + +All contributions to the OpenFeature project are welcome via GitHub pull requests. + +To create a new PR, you will need to first fork the GitHub repository and clone upstream. + +```bash +git clone https://github.com/open-feature/dotnet-sdk.git openfeature-dotnet-sdk +``` + +Navigate to the repository folder + +```bash +cd openfeature-dotnet-sdk +``` + +Add your fork as an origin + +```bash +git remote add fork https://github.com/YOUR_GITHUB_USERNAME/dotnet-sdk.git +``` + +To start working on a new feature or bugfix, create a new branch and start working on it. + +```bash +git checkout -b feat/NAME_OF_FEATURE +# Make your changes +git commit +git push fork feat/NAME_OF_FEATURE +``` + +Open a pull request against the main dotnet-sdk repository. + +### Running tests locally + +#### Unit tests + +To run unit tests execute: + +```bash +dotnet test test/OpenFeature.Tests/ +``` + +#### E2E tests + +To be able to run the e2e tests, first we need to initialize the submodule. + +```bash +git submodule update --init --recursive +``` + +Since all the spec files are copied during the build process. Now you can run the tests using: + +```bash +dotnet test test/OpenFeature.E2ETests/ +``` + +### How to Receive Comments + +- If the PR is not ready for review, please mark it as + [`draft`](https://github.blog/2019-02-14-introducing-draft-pull-requests/). +- Make sure all required CI checks are clear. +- Submit small, focused PRs addressing a single concern/issue. +- Make sure the PR title reflects the contribution. +- Write a summary that helps understand the change. +- Include usage examples in the summary, where applicable. + +### How to Get PRs Merged + +A PR is considered to be **ready to merge** when: + +- Major feedbacks are resolved. +- It has been open for review for at least one working day. This gives people + reasonable time to review. +- Trivial change (typo, cosmetic, doc, etc.) doesn't have to wait for one day. +- Urgent fix can take exception as long as it has been actively communicated. + +Any Maintainer can merge the PR once it is **ready to merge**. Note, that some +PRs may not be merged immediately if the repo is in the process of a release and +the maintainers decided to defer the PR to the next release train. + +If a PR has been stuck (e.g. there are lots of debates and people couldn't agree +on each other), the owner should try to get people aligned by: + +- Consolidating the perspectives and putting a summary in the PR. It is + recommended to add a link into the PR description, which points to a comment + with a summary in the PR conversation. +- Tagging subdomain experts (by looking at the change history) in the PR asking + for suggestion. +- Reaching out to more people on the [CNCF OpenFeature Slack channel](https://cloud-native.slack.com/archives/C0344AANLA1). +- Stepping back to see if it makes sense to narrow down the scope of the PR or + split it up. +- If none of the above worked and the PR has been stuck for more than 2 weeks, + the owner should bring it to the OpenFeatures [meeting](README.md#contributing). + +## Automated Changelog + +Each time a release is published the changelogs will be generated automatically using [googleapis/release-please-action](https://github.com/googleapis/release-please-action). The tool will organise the changes based on the PR labels. +Please make sure you follow the latest [conventions](https://www.conventionalcommits.org/en/v1.0.0/). We use an automation to check if the pull request respects the desired conventions. You can check it [here](https://github.com/open-feature/dotnet-sdk/actions/workflows/lint-pr.yml). Must be one of the following: + +- build: Changes that affect the build system or external dependencies (example scopes: nuget) +- ci: Changes to our CI configuration files and scripts (example scopes: GitHub Actions, Coverage) +- docs: Documentation only changes +- feat: A new feature +- fix: A bug fix +- perf: A code change that improves performance +- refactor: A code change that neither fixes a bug nor adds a feature +- style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) +- test: Adding missing tests or correcting existing tests + +If you want to point out a breaking change, you should use `!` after the type. For example: `feat!: excellent new feature`. + +## Design Choices + +As with other OpenFeature SDKs, dotnet-sdk follows the +[openfeature-specification](https://github.com/open-feature/spec). + +## Style Guide + +This project includes a [`.editorconfig`](./.editorconfig) file which is +supported by all the IDEs/editor mentioned above. It works with the IDE/editor +only and does not affect the actual build of the project. + +## Benchmarking + +We use [BenchmarkDotNet](https://benchmarkdotnet.org/articles/overview.html) NuGet package to benchmark a code. + +To run pipelines locally, you can follow these commands from a root project directory. + +``` +dotnet restore +dotnet build --configuration Release --output "./release" --no-restore +dotnet release/OpenFeature.Benchmarks.dll +``` + +## Consuming pre-release packages + +1. Acquire a [GitHub personal access token (PAT)](https://docs.github.com/github/authenticating-to-github/creating-a-personal-access-token) scoped for `read:packages` and verify the permissions: + + ```console + $ gh auth login --scopes read:packages + + ? What account do you want to log into? GitHub.com + ? What is your preferred protocol for Git operations? HTTPS + ? How would you like to authenticate GitHub CLI? Login with a web browser + + ! First copy your one-time code: ****-**** + Press Enter to open github.com in your browser... + + โœ“ Authentication complete. + - gh config set -h github.com git_protocol https + โœ“ Configured git protocol + โœ“ Logged in as ******** + ``` + + ```console + $ gh auth status + + github.com + โœ“ Logged in to github.com as ******** (~/.config/gh/hosts.yml) + โœ“ Git operations for github.com configured to use https protocol. + โœ“ Token: gho_************************************ + โœ“ Token scopes: gist, read:org, read:packages, repo, workflow + ``` + +2. Run the following command to configure your local environment to consume packages from GitHub Packages: + + ```console + $ dotnet nuget update source github-open-feature --username $(gh api user --jq .email) --password $(gh auth token) --store-password-in-clear-text + + Package source "github-open-feature" was successfully updated. + ``` diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 00000000..6bdfa455 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,42 @@ + + + + true + 8.0.0 + + + 9.0.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/OpenFeature.sln b/OpenFeature.sln index e20ad90f..3b5dc901 100644 --- a/OpenFeature.sln +++ b/OpenFeature.sln @@ -1,13 +1,63 @@ ๏ปฟ Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature", "src\OpenFeature\OpenFeature.csproj", "{07A6E6BD-FB7E-4B3B-9CBE-65AE9D0EB223}" +# Visual Studio Version 17 +VisualStudioVersion = 17.4.33213.308 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".root", ".root", "{E8916D4F-B97E-42D6-8620-ED410A106F94}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + CONTRIBUTING.md = CONTRIBUTING.md + .editorconfig = .editorconfig + .gitignore = .gitignore + .gitmodules = .gitmodules + .release-please-manifest.json = .release-please-manifest.json + CHANGELOG.md = CHANGELOG.md + CODEOWNERS = CODEOWNERS + global.json = global.json + LICENSE = LICENSE + release-please-config.json = release-please-config.json + renovate.json = renovate.json + version.txt = version.txt + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".config", ".config", "{9392E03B-4E6B-434C-8553-B859424388B1}" + ProjectSection(SolutionItems) = preProject + .config\dotnet-tools.json = .config\dotnet-tools.json + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{2B172AA0-A5A6-4D94-BA1F-B79D59B0C2D8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{C4746B8C-FE19-440B-922C-C2377F906FE8}" + ProjectSection(SolutionItems) = preProject + .github\workflows\ci.yml = .github\workflows\ci.yml + .github\workflows\code-coverage.yml = .github\workflows\code-coverage.yml + .github\workflows\codeql-analysis.yml = .github\workflows\codeql-analysis.yml + .github\workflows\dotnet-format.yml = .github\workflows\dotnet-format.yml + .github\workflows\e2e.yml = .github\workflows\e2e.yml + .github\workflows\lint-pr.yml = .github\workflows\lint-pr.yml + .github\workflows\release.yml = .github\workflows\release.yml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ISSUE_TEMPLATE", "ISSUE_TEMPLATE", "{09BAB3A2-E94C-490A-861C-7D1E11BB7024}" + ProjectSection(SolutionItems) = preProject + .github\ISSUE_TEMPLATE\bug.yaml = .github\ISSUE_TEMPLATE\bug.yaml + .github\ISSUE_TEMPLATE\documentation.yaml = .github\ISSUE_TEMPLATE\documentation.yaml + .github\ISSUE_TEMPLATE\feature.yaml = .github\ISSUE_TEMPLATE\feature.yaml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".vscode", ".vscode", "{4BB69DB3-9653-4197-9589-37FA6D658CB7}" + ProjectSection(SolutionItems) = preProject + .vscode\launch.json = .vscode\launch.json + .vscode\tasks.json = .vscode\tasks.json + EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{72005F60-C2E8-40BF-AE95-893635134D7D}" ProjectSection(SolutionItems) = preProject + build\Common.prod.props = build\Common.prod.props build\Common.props = build\Common.props build\Common.tests.props = build\Common.tests.props - build\Common.prod.props = build\Common.prod.props - build\RELEASING.md = build\RELEASING.md + build\openfeature-icon.png = build\openfeature-icon.png + build\xunit.runner.json = build\xunit.runner.json EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C97E9975-E10A-4817-AE2C-4DD69C3C02D4}" @@ -21,7 +71,21 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{65FBA159-2 test\Directory.Build.props = test\Directory.Build.props EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Tests", "test\OpenFeature.Tests\OpenFeature.Tests.csproj", "{49BB42BA-10A6-4DA3-A7D5-38C968D57837}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature", "src\OpenFeature\OpenFeature.csproj", "{07A6E6BD-FB7E-4B3B-9CBE-65AE9D0EB223}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature.Tests", "test\OpenFeature.Tests\OpenFeature.Tests.csproj", "{49BB42BA-10A6-4DA3-A7D5-38C968D57837}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature.Benchmarks", "test\OpenFeature.Benchmarks\OpenFeature.Benchmarks.csproj", "{90E7EAD3-251E-4490-AF78-E758E33518E5}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature.E2ETests", "test\OpenFeature.E2ETests\OpenFeature.E2ETests.csproj", "{7398C446-2630-4F8C-9278-4E807720E9E5}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature.DependencyInjection", "src\OpenFeature.DependencyInjection\OpenFeature.DependencyInjection.csproj", "{C5415057-2700-48B5-940A-7A10969FA639}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature.DependencyInjection.Tests", "test\OpenFeature.DependencyInjection.Tests\OpenFeature.DependencyInjection.Tests.csproj", "{EB35F9F6-8A79-410E-A293-9387BC4AC9A7}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature.Hosting", "src\OpenFeature.Hosting\OpenFeature.Hosting.csproj", "{C99DA02A-3981-45A6-B3F8-4A1A48653DEE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.IntegrationTests", "test\OpenFeature.IntegrationTests\OpenFeature.IntegrationTests.csproj", "{68463B47-36B4-8DB5-5D02-662C169E85B0}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -37,9 +101,51 @@ Global {49BB42BA-10A6-4DA3-A7D5-38C968D57837}.Debug|Any CPU.Build.0 = Debug|Any CPU {49BB42BA-10A6-4DA3-A7D5-38C968D57837}.Release|Any CPU.ActiveCfg = Release|Any CPU {49BB42BA-10A6-4DA3-A7D5-38C968D57837}.Release|Any CPU.Build.0 = Release|Any CPU + {90E7EAD3-251E-4490-AF78-E758E33518E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {90E7EAD3-251E-4490-AF78-E758E33518E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {90E7EAD3-251E-4490-AF78-E758E33518E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {90E7EAD3-251E-4490-AF78-E758E33518E5}.Release|Any CPU.Build.0 = Release|Any CPU + {7398C446-2630-4F8C-9278-4E807720E9E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7398C446-2630-4F8C-9278-4E807720E9E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7398C446-2630-4F8C-9278-4E807720E9E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7398C446-2630-4F8C-9278-4E807720E9E5}.Release|Any CPU.Build.0 = Release|Any CPU + {C5415057-2700-48B5-940A-7A10969FA639}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C5415057-2700-48B5-940A-7A10969FA639}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C5415057-2700-48B5-940A-7A10969FA639}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C5415057-2700-48B5-940A-7A10969FA639}.Release|Any CPU.Build.0 = Release|Any CPU + {EB35F9F6-8A79-410E-A293-9387BC4AC9A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB35F9F6-8A79-410E-A293-9387BC4AC9A7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB35F9F6-8A79-410E-A293-9387BC4AC9A7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB35F9F6-8A79-410E-A293-9387BC4AC9A7}.Release|Any CPU.Build.0 = Release|Any CPU + {C99DA02A-3981-45A6-B3F8-4A1A48653DEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C99DA02A-3981-45A6-B3F8-4A1A48653DEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C99DA02A-3981-45A6-B3F8-4A1A48653DEE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C99DA02A-3981-45A6-B3F8-4A1A48653DEE}.Release|Any CPU.Build.0 = Release|Any CPU + {68463B47-36B4-8DB5-5D02-662C169E85B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {68463B47-36B4-8DB5-5D02-662C169E85B0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {68463B47-36B4-8DB5-5D02-662C169E85B0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {68463B47-36B4-8DB5-5D02-662C169E85B0}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution + {9392E03B-4E6B-434C-8553-B859424388B1} = {E8916D4F-B97E-42D6-8620-ED410A106F94} + {2B172AA0-A5A6-4D94-BA1F-B79D59B0C2D8} = {E8916D4F-B97E-42D6-8620-ED410A106F94} + {C4746B8C-FE19-440B-922C-C2377F906FE8} = {2B172AA0-A5A6-4D94-BA1F-B79D59B0C2D8} + {09BAB3A2-E94C-490A-861C-7D1E11BB7024} = {2B172AA0-A5A6-4D94-BA1F-B79D59B0C2D8} + {4BB69DB3-9653-4197-9589-37FA6D658CB7} = {E8916D4F-B97E-42D6-8620-ED410A106F94} + {72005F60-C2E8-40BF-AE95-893635134D7D} = {E8916D4F-B97E-42D6-8620-ED410A106F94} {07A6E6BD-FB7E-4B3B-9CBE-65AE9D0EB223} = {C97E9975-E10A-4817-AE2C-4DD69C3C02D4} {49BB42BA-10A6-4DA3-A7D5-38C968D57837} = {65FBA159-23E0-4CF9-881B-F78DBFF198E9} + {90E7EAD3-251E-4490-AF78-E758E33518E5} = {65FBA159-23E0-4CF9-881B-F78DBFF198E9} + {7398C446-2630-4F8C-9278-4E807720E9E5} = {65FBA159-23E0-4CF9-881B-F78DBFF198E9} + {C5415057-2700-48B5-940A-7A10969FA639} = {C97E9975-E10A-4817-AE2C-4DD69C3C02D4} + {EB35F9F6-8A79-410E-A293-9387BC4AC9A7} = {65FBA159-23E0-4CF9-881B-F78DBFF198E9} + {C99DA02A-3981-45A6-B3F8-4A1A48653DEE} = {C97E9975-E10A-4817-AE2C-4DD69C3C02D4} + {68463B47-36B4-8DB5-5D02-662C169E85B0} = {65FBA159-23E0-4CF9-881B-F78DBFF198E9} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {41F01B78-FB06-404F-8AD0-6ED6973F948F} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 1ed43899..b0063d3a 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,525 @@ -# OpenFeature SDK for .NET - -[![a](https://img.shields.io/badge/slack-%40cncf%2Fopenfeature-brightgreen?style=flat&logo=slack)](https://cloud-native.slack.com/archives/C0344AANLA1) -[![Project Status: WIP โ€“ Initial development is in progress, but there has not yet been a stable, usable release suitable for the public.](https://www.repostatus.org/badges/latest/wip.svg)]() -[![codecov](https://codecov.io/gh/open-feature/dotnet-sdk/branch/main/graph/badge.svg?token=MONAVJBXUJ)](https://codecov.io/gh/open-feature/dotnet-sdk) -[![nuget](https://img.shields.io/nuget/vpre/OpenFeature)](https://www.nuget.org/packages/OpenFeature) - -OpenFeature is an open standard for feature flag management, created to support a robust feature flag ecosystem using cloud native technologies. OpenFeature will provide a unified API and SDK, and a developer-first, cloud-native implementation, with extensibility for open source and commercial offerings. - -## Supported .Net Versions - -The packages will aim to support all current .NET versions. Refer to the currently supported versions [.NET](https://dotnet.microsoft.com/download/dotnet) and [.NET Framework](https://dotnet.microsoft.com/download/dotnet-framework) Excluding .NET Framework 3.5 - -## Providers - -| Provider | Package Name | -| ----------- | ----------- | -| TBA | TBA | - -## Basic Usage - -```csharp -using OpenFeature.SDK; - -OpenFeature.Instance.SetProvider(new NoOpProvider()); -var client = OpenFeature.Instance.GetClient(); - -var isEnabled = await client.GetBooleanValue("my-feature", false); -``` - -## Contributors - -Thanks so much to your contributions to the OpenFeature project. - - - - - -Made with [contrib.rocks](https://contrib.rocks). + + + + +![OpenFeature Dark Logo](https://raw.githubusercontent.com/open-feature/community/0e23508c163a6a1ac8c0ced3e4bd78faafe627c7/assets/logo/horizontal/black/openfeature-horizontal-black.svg) + +## .NET SDK + + + +[![Specification](https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge)](https://github.com/open-feature/spec/releases/tag/v0.8.0) +[ +![Release](https://img.shields.io/static/v1?label=release&message=v2.5.0&color=blue&style=for-the-badge) +](https://github.com/open-feature/dotnet-sdk/releases/tag/v2.5.0) + +[![Slack](https://img.shields.io/badge/slack-%40cncf%2Fopenfeature-brightgreen?style=flat&logo=slack)](https://cloud-native.slack.com/archives/C0344AANLA1) +[![Codecov](https://codecov.io/gh/open-feature/dotnet-sdk/branch/main/graph/badge.svg?token=MONAVJBXUJ)](https://codecov.io/gh/open-feature/dotnet-sdk) +[![NuGet](https://img.shields.io/nuget/vpre/OpenFeature)](https://www.nuget.org/packages/OpenFeature) +[![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/6250/badge)](https://www.bestpractices.dev/en/projects/6250) + + + +[OpenFeature](https://openfeature.dev) is an open specification that provides a vendor-agnostic, community-driven API for feature flagging that works with your favorite feature flag management tool or in-house solution. + + + +## ๐Ÿš€ Quick start + +### Requirements + +- .NET 8+ +- .NET Framework 4.6.2+ + +Note that the packages will aim to support all current .NET versions. Refer to the currently supported versions [.NET](https://dotnet.microsoft.com/download/dotnet) and [.NET Framework](https://dotnet.microsoft.com/download/dotnet-framework) excluding .NET Framework 3.5 + +### Install + +Use the following to initialize your project: + +```sh +dotnet new console +``` + +and install OpenFeature: + +```sh +dotnet add package OpenFeature +``` + +### Usage + +```csharp +public async Task Example() +{ + // Register your feature flag provider + try + { + await Api.Instance.SetProviderAsync(new InMemoryProvider()); + } + catch (Exception ex) + { + // Log error + } + + // Create a new client + FeatureClient client = Api.Instance.GetClient(); + + // Evaluate your feature flag + bool v2Enabled = await client.GetBooleanValueAsync("v2_enabled", false); + + if ( v2Enabled ) + { + // Do some work + } +} +``` + +## ๐ŸŒŸ Features + +| Status | Features | Description | +| ------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| โœ… | [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. | +| โœ… | [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). | +| โœ… | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. | +| โœ… | [Tracking](#tracking) | Associate user actions with feature flag evaluations. | +| โœ… | [Logging](#logging) | Integrate with popular logging packages. | +| โœ… | [Domains](#domains) | Logically bind clients with providers. | +| โœ… | [Eventing](#eventing) | React to state changes in the provider or flag management system. | +| โœ… | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. | +| โœ… | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread). | +| โœ… | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. | +| ๐Ÿ”ฌ | [DependencyInjection](#DependencyInjection) | Integrate OpenFeature with .NET's dependency injection for streamlined provider setup. | + +> Implemented: โœ… | In-progress: โš ๏ธ | Not implemented yet: โŒ | Experimental: ๐Ÿ”ฌ + +### Providers + +[Providers](https://openfeature.dev/docs/reference/concepts/provider) are an abstraction between a flag management system and the OpenFeature SDK. +Here is [a complete list of available providers](https://openfeature.dev/ecosystem?instant_search%5BrefinementList%5D%5Btype%5D%5B0%5D=Provider&instant_search%5BrefinementList%5D%5Btechnology%5D%5B0%5D=.NET). + +If the provider you're looking for hasn't been created yet, see the [develop a provider](#develop-a-provider) section to learn how to build it yourself. + +Once you've added a provider as a dependency, it can be registered with OpenFeature like this: + +```csharp +try +{ + await Api.Instance.SetProviderAsync(new MyProvider()); +} +catch (Exception ex) +{ + // Log error +} +``` + +When calling `SetProviderAsync` an exception may be thrown if the provider cannot be initialized. This may occur if the provider has not been configured correctly. See the documentation for the provider you are using for more information on how to configure the provider correctly. + +In some situations, it may be beneficial to register multiple providers in the same application. +This is possible using [domains](#domains), which is covered in more detail below. + +### Targeting + +Sometimes, the value of a flag must consider some dynamic criteria about the application or user such as the user's location, IP, email address, or the server's location. +In OpenFeature, we refer to this as [targeting](https://openfeature.dev/specification/glossary#targeting). +If the flag management system you're using supports targeting, you can provide the input data using the [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). + +```csharp +// set a value to the global context +EvaluationContextBuilder builder = EvaluationContext.Builder(); +builder.Set("region", "us-east-1"); +EvaluationContext apiCtx = builder.Build(); +Api.Instance.SetContext(apiCtx); + +// set a value to the client context +builder = EvaluationContext.Builder(); +builder.Set("region", "us-east-1"); +EvaluationContext clientCtx = builder.Build(); +var client = Api.Instance.GetClient(); +client.SetContext(clientCtx); + +// set a value to the invocation context +builder = EvaluationContext.Builder(); +builder.Set("region", "us-east-1"); +EvaluationContext reqCtx = builder.Build(); + +bool flagValue = await client.GetBooleanValuAsync("some-flag", false, reqCtx); + +``` + +### Hooks + +[Hooks](https://openfeature.dev/docs/reference/concepts/hooks) allow for custom logic to be added at well-defined points of the flag evaluation life-cycle. +Look [here](https://openfeature.dev/ecosystem/?instant_search%5BrefinementList%5D%5Btype%5D%5B0%5D=Hook&instant_search%5BrefinementList%5D%5Bcategory%5D%5B0%5D=Server-side&instant_search%5BrefinementList%5D%5Btechnology%5D%5B0%5D=.NET) for a complete list of available hooks. +If the hook you're looking for hasn't been created yet, see the [develop a hook](#develop-a-hook) section to learn how to build it yourself. + +Once you've added a hook as a dependency, it can be registered at the global, client, or flag invocation level. + +```csharp +// add a hook globally, to run on all evaluations +Api.Instance.AddHooks(new ExampleGlobalHook()); + +// add a hook on this client, to run on all evaluations made by this client +var client = Api.Instance.GetClient(); +client.AddHooks(new ExampleClientHook()); + +// add a hook for this evaluation only +var value = await client.GetBooleanValueAsync("boolFlag", false, context, new FlagEvaluationOptions(new ExampleInvocationHook())); +``` + +### Logging + +The .NET SDK uses Microsoft.Extensions.Logging. See the [manual](https://learn.microsoft.com/en-us/dotnet/core/extensions/logging?tabs=command-line) for complete documentation. +Note that in accordance with the OpenFeature specification, the SDK doesn't generally log messages during flag evaluation. If you need further troubleshooting, please look into the `Logging Hook` section. + +#### Logging Hook + +The .NET SDK includes a LoggingHook, which logs detailed information at key points during flag evaluation, using Microsoft.Extensions.Logging structured logging API. This hook can be particularly helpful for troubleshooting and debugging; simply attach it at the global, client or invocation level and ensure your log level is set to "debug". + +```csharp +using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); +var logger = loggerFactory.CreateLogger("Program"); + +var client = Api.Instance.GetClient(); +client.AddHooks(new LoggingHook(logger)); +``` + +See [hooks](#hooks) for more information on configuring hooks. + +### Domains + +Clients can be assigned to a domain. +A domain is a logical identifier which can be used to associate clients with a particular provider. +If a domain has no associated provider, the default provider is used. + +```csharp +try +{ + // registering the default provider + await Api.Instance.SetProviderAsync(new LocalProvider()); + + // registering a provider to a domain + await Api.Instance.SetProviderAsync("clientForCache", new CachedProvider()); +} +catch (Exception ex) +{ + // Log error +} + +// a client backed by default provider +FeatureClient clientDefault = Api.Instance.GetClient(); + +// a client backed by CachedProvider +FeatureClient scopedClient = Api.Instance.GetClient("clientForCache"); +``` + +Domains can be defined on a provider during registration. +For more details, please refer to the [providers](#providers) section. + +### Eventing + +Events allow you to react to state changes in the provider or underlying flag management system, such as flag definition changes, +provider readiness, or error conditions. +Initialization events (`PROVIDER_READY` on success, `PROVIDER_ERROR` on failure) are dispatched for every provider. +Some providers support additional events, such as `PROVIDER_CONFIGURATION_CHANGED`. + +Please refer to the documentation of the provider you're using to see what events are supported. + +Example usage of an Event handler: + +```csharp +public static void EventHandler(ProviderEventPayload eventDetails) +{ + Console.WriteLine(eventDetails.Type); +} +``` + +```csharp +EventHandlerDelegate callback = EventHandler; +// add an implementation of the EventHandlerDelegate for the PROVIDER_READY event +Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, callback); +``` + +It is also possible to register an event handler for a specific client, as in the following example: + +```csharp +EventHandlerDelegate callback = EventHandler; + +var myClient = Api.Instance.GetClient("my-client"); + +try +{ + var provider = new ExampleProvider(); + await Api.Instance.SetProviderAsync(myClient.GetMetadata().Name, provider); +} +catch (Exception ex) +{ + // Log error +} + +myClient.AddHandler(ProviderEventTypes.ProviderReady, callback); +``` + +### Tracking + +The [tracking API](https://openfeature.dev/specification/sections/tracking) allows you to use OpenFeature abstractions and objects to associate user actions with feature flag evaluations. +This is essential for robust experimentation powered by feature flags. +For example, a flag enhancing the appearance of a UI component might drive user engagement to a new feature; to test this hypothesis, telemetry collected by a hook(#hooks) or provider(#providers) can be associated with telemetry reported in the client's `track` function. + +```csharp +var client = Api.Instance.GetClient(); +client.Track("visited-promo-page", trackingEventDetails: new TrackingEventDetailsBuilder().SetValue(99.77).Set("currency", "USD").Build()); +``` + +Note that some providers may not support tracking; check the documentation for your provider for more information. + +### Shutdown + +The OpenFeature API provides a close function to perform a cleanup of all registered providers. This should only be called when your application is in the process of shutting down. + +```csharp +// Shut down all providers +await Api.Instance.ShutdownAsync(); +``` + +### Transaction Context Propagation + +Transaction context is a container for transaction-specific evaluation context (e.g. user id, user agent, IP). +Transaction context can be set where specific data is available (e.g. an auth service or request handler) and by using the transaction context propagator it will automatically be applied to all flag evaluations within a transaction (e.g. a request or thread). +By default, the `NoOpTransactionContextPropagator` is used, which doesn't store anything. +To register a [AsyncLocal](https://learn.microsoft.com/en-us/dotnet/api/system.threading.asynclocal-1) context propagator, you can use the `SetTransactionContextPropagator` method as shown below. + +```csharp +// registering the AsyncLocalTransactionContextPropagator +Api.Instance.SetTransactionContextPropagator(new AsyncLocalTransactionContextPropagator()); +``` + +Once you've registered a transaction context propagator, you can propagate the data into request-scoped transaction context. + +```csharp +// adding userId to transaction context +EvaluationContext transactionContext = EvaluationContext.Builder() + .Set("userId", userId) + .Build(); +Api.Instance.SetTransactionContext(transactionContext); +``` + +Additionally, you can develop a custom transaction context propagator by implementing the `TransactionContextPropagator` interface and registering it as shown above. + +## Extending + +### Develop a provider + +To develop a provider, you need to create a new project and include the OpenFeature SDK as a dependency. +This can be a new repository or included in [the existing contrib repository](https://github.com/open-feature/dotnet-sdk-contrib) available under the OpenFeature organization. +Youโ€™ll then need to write the provider by implementing the `FeatureProvider` interface exported by the OpenFeature SDK. + +```csharp +public class MyProvider : FeatureProvider +{ + public override Metadata GetMetadata() + { + return new Metadata("My Provider"); + } + + public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + // resolve a boolean flag value + } + + public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + // resolve a string flag value + } + + public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext context = null) + { + // resolve an int flag value + } + + public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + // resolve a double flag value + } + + public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + // resolve an object flag value + } +} +``` + +### Develop a hook + +To develop a hook, you need to create a new project and include the OpenFeature SDK as a dependency. +This can be a new repository or included in [the existing contrib repository](https://github.com/open-feature/dotnet-sdk-contrib) available under the OpenFeature organization. +Implement your own hook by conforming to the `Hook interface`. +To satisfy the interface, all methods (`Before`/`After`/`Finally`/`Error`) need to be defined. + +```csharp +public class MyHook : Hook +{ + public ValueTask BeforeAsync(HookContext context, + IReadOnlyDictionary hints = null) + { + // code to run before flag evaluation + } + + public ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, + IReadOnlyDictionary? hints = null) + { + // code to run after successful flag evaluation + } + + public ValueTask ErrorAsync(HookContext context, Exception error, + IReadOnlyDictionary? hints = null) + { + // code to run if there's an error during before hooks or during flag evaluation + } + + public ValueTask FinallyAsync(HookContext context, FlagEvaluationDetails evaluationDetails, IReadOnlyDictionary hints = null) + { + // code to run after all other stages, regardless of success/failure + } +} +``` + +Hooks support passing per-evaluation data between that stages using `hook data`. The below example hook uses `hook data` to measure the duration between the execution of the `before` and `after` stage. + +```csharp + class TimingHook : Hook + { + public ValueTask BeforeAsync(HookContext context, + IReadOnlyDictionary? hints = null) + { + context.Data.Set("beforeTime", DateTime.Now); + return ValueTask.FromResult(context.EvaluationContext); + } + + public ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, + IReadOnlyDictionary? hints = null) + { + var beforeTime = context.Data.Get("beforeTime") as DateTime?; + var duration = DateTime.Now - beforeTime; + Console.WriteLine($"Duration: {duration}"); + return ValueTask.CompletedTask; + } + } +``` + +Built a new hook? [Let us know](https://github.com/open-feature/openfeature.dev/issues/new?assignees=&labels=hook&projects=&template=document-hook.yaml&title=%5BHook%5D%3A+) so we can add it to the docs! + +### DependencyInjection + +> [!NOTE] +> The OpenFeature.DependencyInjection and OpenFeature.Hosting packages are currently experimental. They streamline the integration of OpenFeature within .NET applications, allowing for seamless configuration and lifecycle management of feature flag providers using dependency injection and hosting services. + +#### Installation + +To set up dependency injection and hosting capabilities for OpenFeature, install the following packages: + +```sh +dotnet add package OpenFeature.DependencyInjection +dotnet add package OpenFeature.Hosting +``` + +#### Usage Examples + +For a basic configuration, you can use the InMemoryProvider. This provider is simple and well-suited for development and testing purposes. + +**Basic Configuration:** + +```csharp +builder.Services.AddOpenFeature(featureBuilder => { + featureBuilder + .AddHostedFeatureLifecycle() // From Hosting package + .AddContext((contextBuilder, serviceProvider) => { /* Custom context configuration */ }) + .AddHook() + .AddInMemoryProvider(); +}); +``` + +**Domain-Scoped Provider Configuration:** +
To set up multiple providers with a selection policy, define logic for choosing the default provider. This example designates `name1` as the default provider: + +```csharp +builder.Services.AddOpenFeature(featureBuilder => { + featureBuilder + .AddHostedFeatureLifecycle() + .AddContext((contextBuilder, serviceProvider) => { /* Custom context configuration */ }) + .AddHook((serviceProvider) => new LoggingHook( /* Custom configuration */ )) + .AddInMemoryProvider("name1") + .AddInMemoryProvider("name2") + .AddPolicyName(options => { + // Custom logic to select a default provider + options.DefaultNameSelector = serviceProvider => "name1"; + }); +}); +``` + +### Registering a Custom Provider + +You can register a custom provider, such as `InMemoryProvider`, with OpenFeature using the `AddProvider` method. This approach allows you to dynamically resolve services or configurations during registration. + +```csharp +services.AddOpenFeature(builder => +{ + builder.AddProvider(provider => + { + // Resolve services or configurations as needed + var variants = new Dictionary { { "on", true } }; + var flags = new Dictionary + { + { "feature-key", new Flag(variants, "on") } + }; + + // Register a custom provider, such as InMemoryProvider + return new InMemoryProvider(flags); + }); +}); +``` + +#### Adding a Domain-Scoped Provider + +You can also register a domain-scoped custom provider, enabling configurations specific to each domain: + +```csharp +services.AddOpenFeature(builder => +{ + builder.AddProvider("my-domain", (provider, domain) => + { + // Resolve services or configurations as needed for the domain + var variants = new Dictionary { { "on", true } }; + var flags = new Dictionary + { + { $"{domain}-feature-key", new Flag(variants, "on") } + }; + + // Register a domain-scoped custom provider such as InMemoryProvider + return new InMemoryProvider(flags); + }); +}); +``` + + + +## โญ๏ธ Support the project + +- Give this repo a โญ๏ธ! +- Follow us on social media: + - Twitter: [@openfeature](https://twitter.com/openfeature) + - LinkedIn: [OpenFeature](https://www.linkedin.com/company/openfeature/) +- Join us on [Slack](https://cloud-native.slack.com/archives/C0344AANLA1) +- For more information, check out our [community page](https://openfeature.dev/community/) + +## ๐Ÿค Contributing + +Interested in contributing? Great, we'd love your help! To get started, take a look at the [CONTRIBUTING](CONTRIBUTING.md) guide. + +### Thanks to everyone who has already contributed + +[![Contrib Rocks](https://contrib.rocks/image?repo=open-feature/dotnet-sdk)](https://github.com/open-feature/dotnet-sdk/graphs/contributors) + +Made with [contrib.rocks](https://contrib.rocks). + + diff --git a/build/Common.prod.props b/build/Common.prod.props index aacfc0c7..3f4ba37d 100644 --- a/build/Common.prod.props +++ b/build/Common.prod.props @@ -1,48 +1,31 @@ - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - + true + true true + true - 0.1 - - - + 2.5.0 git https://github.com/open-feature/dotnet-sdk + OpenFeature is an open standard for feature flag management, created to support a robust feature flag ecosystem using cloud native technologies. OpenFeature will provide a unified API and SDK, and a developer-first, cloud-native implementation, with extensibility for open source and commercial offerings. Feature;OpenFeature;Flags; openfeature-icon.png https://openfeature.dev Apache-2.0 OpenFeature Authors true + $(VersionNumber) + $(VersionNumber) + $(VersionNumber) - - true - true - true - snupkg - - - - - - - - true - diff --git a/build/Common.props b/build/Common.props index fa9b7e2a..1c769bcf 100644 --- a/build/Common.props +++ b/build/Common.props @@ -1,7 +1,16 @@ - 7.3 + latest true + true + true + + EnableGenerateDocumentationFile + enable + true + true + all + low @@ -13,13 +22,13 @@ true - - - [4.1.0,5.0) - [2.0,6.0) - [1.0.0,2.0) - + + + + + + + + + diff --git a/build/Common.tests.props b/build/Common.tests.props index d4a6454e..ac9a6453 100644 --- a/build/Common.tests.props +++ b/build/Common.tests.props @@ -5,27 +5,22 @@ false - + true - + PreserveNewest - - - [4.17.0] - [3.1.2] - [6.7.0] - [17.2.0] - [4.18.1] - [2.4.3,3.0) - [2.4.1,3.0) + + + + + + + $(NoWarn);CA2007 diff --git a/build/RELEASING.md b/build/RELEASING.md deleted file mode 100644 index a8b28b19..00000000 --- a/build/RELEASING.md +++ /dev/null @@ -1,19 +0,0 @@ -๏ปฟ# Release process - -Only for release managers - -1. Decide on the version name to be released. e.g. 0.1.0, 0.1.1 etc -2. Tag the commit with the version number -```shell -git tag -a 0.1.0 -m "0.1.0" -git push origin 0.1.0 -``` -3. Build and pack the code -```shell -dotnet build --configuration Release --no-restore -p:Deterministic=true -dotnet pack OpenFeature.proj --configuration Release --no-build -``` -4. Push up the package to nuget -```shell -dotnet nuget push OpenFeature.{VERSION}.nupkg --api-key {API_KEY} --source https://api.nuget.org/v3/index.json -``` diff --git a/build/xunit.runner.json b/build/xunit.runner.json index c4dcd538..47a03b98 100644 --- a/build/xunit.runner.json +++ b/build/xunit.runner.json @@ -1,4 +1,5 @@ { "maxParallelThreads": 1, - "parallelizeTestCollections": false -} \ No newline at end of file + "parallelizeTestCollections": false, + "parallelizeAssembly": false +} diff --git a/global.json b/global.json index 69fd3584..3018f657 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,7 @@ { "sdk": { "rollForward": "latestFeature", - "version": "6.0.100" + "version": "9.0.202", + "allowPrerelease": false } -} +} \ No newline at end of file diff --git a/nuget.config b/nuget.config new file mode 100644 index 00000000..5a0edf43 --- /dev/null +++ b/nuget.config @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 00000000..e79a24e5 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,69 @@ +{ + "packages": { + ".": { + "release-type": "simple", + "monorepo-tags": false, + "include-component-in-tag": false, + "versioning": "default", + "extra-files": [ + "build/Common.prod.props", + "README.md" + ] + } + }, + "changelog-sections": [ + { + "type": "fix", + "section": "๐Ÿ› Bug Fixes" + }, + { + "type": "feat", + "section": "โœจ New Features" + }, + { + "type": "chore", + "section": "๐Ÿงน Chore" + }, + { + "type": "docs", + "section": "๐Ÿ“š Documentation" + }, + { + "type": "perf", + "section": "๐Ÿš€ Performance" + }, + { + "type": "build", + "hidden": true, + "section": "๐Ÿ› ๏ธ Build" + }, + { + "type": "deps", + "section": "๐Ÿ“ฆ Dependencies" + }, + { + "type": "ci", + "hidden": true, + "section": "๐Ÿšฆ CI" + }, + { + "type": "refactor", + "section": "๐Ÿ”„ Refactoring" + }, + { + "type": "revert", + "section": "๐Ÿ”™ Reverts" + }, + { + "type": "style", + "hidden": true, + "section": "๐ŸŽจ Styling" + }, + { + "type": "test", + "hidden": true, + "section": "๐Ÿงช Tests" + } + ], + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..151c402c --- /dev/null +++ b/renovate.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "github>open-feature/community-tooling" + ], + "dependencyDashboardApproval": true, + "recreateWhen": "never" +} diff --git a/spec b/spec new file mode 160000 index 00000000..d27e000b --- /dev/null +++ b/spec @@ -0,0 +1 @@ +Subproject commit d27e000b6c839b533ff4f3ea0f5b1bfc024fb534 diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 44742c36..bef896bf 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -1,11 +1,4 @@ - - - all - runtime; build; native; contentfiles; analyzers - - - $([System.IO.Path]::Combine('$(IntermediateOutputPath)','$(TargetFrameworkMoniker).AssemblyAttributes$(DefaultLanguageSourceExtension)')) diff --git a/src/OpenFeature.DependencyInjection/Diagnostics/FeatureCodes.cs b/src/OpenFeature.DependencyInjection/Diagnostics/FeatureCodes.cs new file mode 100644 index 00000000..582ab39c --- /dev/null +++ b/src/OpenFeature.DependencyInjection/Diagnostics/FeatureCodes.cs @@ -0,0 +1,38 @@ +namespace OpenFeature.DependencyInjection.Diagnostics; + +/// +/// Contains identifiers for experimental features and diagnostics in the OpenFeature framework. +/// +/// +/// Experimental - This class includes identifiers that allow developers to track and conditionally enable +/// experimental features. Each identifier follows a structured code format to indicate the feature domain, +/// maturity level, and unique identifier. Note that experimental features are subject to change or removal +/// in future releases. +/// +/// Basic Information
+/// These identifiers conform to OpenFeatureโ€™s Diagnostics Specifications, allowing developers to recognize +/// and manage experimental features effectively. +///
+///
+/// +/// +/// Code Structure: +/// - "OF" - Represents the OpenFeature library. +/// - "DI" - Indicates the Dependency Injection domain. +/// - "001" - Unique identifier for a specific feature. +/// +/// +internal static class FeatureCodes +{ + /// + /// Identifier for the experimental Dependency Injection features within the OpenFeature framework. + /// + /// + /// OFDI001 identifier marks experimental features in the Dependency Injection (DI) domain. + /// + /// Usage: + /// Developers can use this identifier to conditionally enable or test experimental DI features. + /// It is part of the OpenFeature diagnostics system to help track experimental functionality. + /// + public const string NewDi = "OFDI001"; +} diff --git a/src/OpenFeature.DependencyInjection/Guard.cs b/src/OpenFeature.DependencyInjection/Guard.cs new file mode 100644 index 00000000..337a8290 --- /dev/null +++ b/src/OpenFeature.DependencyInjection/Guard.cs @@ -0,0 +1,20 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace OpenFeature.DependencyInjection; + +[DebuggerStepThrough] +internal static class Guard +{ + public static void ThrowIfNull(object? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) + { + if (argument is null) + throw new ArgumentNullException(paramName); + } + + public static void ThrowIfNullOrWhiteSpace(string? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) + { + if (string.IsNullOrWhiteSpace(argument)) + throw new ArgumentNullException(paramName); + } +} diff --git a/src/OpenFeature.DependencyInjection/IFeatureLifecycleManager.cs b/src/OpenFeature.DependencyInjection/IFeatureLifecycleManager.cs new file mode 100644 index 00000000..4891f2e8 --- /dev/null +++ b/src/OpenFeature.DependencyInjection/IFeatureLifecycleManager.cs @@ -0,0 +1,24 @@ +namespace OpenFeature.DependencyInjection; + +/// +/// Defines the contract for managing the lifecycle of a feature api. +/// +public interface IFeatureLifecycleManager +{ + /// + /// Ensures that the feature provider is properly initialized and ready to be used. + /// This method should handle all necessary checks, configuration, and setup required to prepare the feature provider. + /// + /// Propagates notification that operations should be canceled. + /// A Task representing the asynchronous operation of initializing the feature provider. + /// Thrown when the feature provider is not registered or is in an invalid state. + ValueTask EnsureInitializedAsync(CancellationToken cancellationToken = default); + + /// + /// Gracefully shuts down the feature api, ensuring all resources are properly disposed of and any persistent state is saved. + /// This method should handle all necessary cleanup and shutdown operations for the feature provider. + /// + /// Propagates notification that operations should be canceled. + /// A Task representing the asynchronous operation of shutting down the feature provider. + ValueTask ShutdownAsync(CancellationToken cancellationToken = default); +} diff --git a/src/OpenFeature.DependencyInjection/Internal/FeatureLifecycleManager.cs b/src/OpenFeature.DependencyInjection/Internal/FeatureLifecycleManager.cs new file mode 100644 index 00000000..f2c914f2 --- /dev/null +++ b/src/OpenFeature.DependencyInjection/Internal/FeatureLifecycleManager.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace OpenFeature.DependencyInjection.Internal; + +internal sealed partial class FeatureLifecycleManager : IFeatureLifecycleManager +{ + private readonly Api _featureApi; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public FeatureLifecycleManager(Api featureApi, IServiceProvider serviceProvider, ILogger logger) + { + _featureApi = featureApi; + _serviceProvider = serviceProvider; + _logger = logger; + } + + /// + public async ValueTask EnsureInitializedAsync(CancellationToken cancellationToken = default) + { + this.LogStartingInitializationOfFeatureProvider(); + + var options = _serviceProvider.GetRequiredService>().Value; + if (options.HasDefaultProvider) + { + var featureProvider = _serviceProvider.GetRequiredService(); + await _featureApi.SetProviderAsync(featureProvider).ConfigureAwait(false); + } + + foreach (var name in options.ProviderNames) + { + var featureProvider = _serviceProvider.GetRequiredKeyedService(name); + await _featureApi.SetProviderAsync(name, featureProvider).ConfigureAwait(false); + } + + var hooks = new List(); + foreach (var hookName in options.HookNames) + { + var hook = _serviceProvider.GetRequiredKeyedService(hookName); + hooks.Add(hook); + } + + _featureApi.AddHooks(hooks); + } + + /// + public async ValueTask ShutdownAsync(CancellationToken cancellationToken = default) + { + this.LogShuttingDownFeatureProvider(); + await _featureApi.ShutdownAsync().ConfigureAwait(false); + } + + [LoggerMessage(200, LogLevel.Information, "Starting initialization of the feature provider")] + partial void LogStartingInitializationOfFeatureProvider(); + + [LoggerMessage(200, LogLevel.Information, "Shutting down the feature provider")] + partial void LogShuttingDownFeatureProvider(); +} diff --git a/src/OpenFeature.DependencyInjection/MultiTarget/CallerArgumentExpressionAttribute.cs b/src/OpenFeature.DependencyInjection/MultiTarget/CallerArgumentExpressionAttribute.cs new file mode 100644 index 00000000..afbec6b0 --- /dev/null +++ b/src/OpenFeature.DependencyInjection/MultiTarget/CallerArgumentExpressionAttribute.cs @@ -0,0 +1,23 @@ +// @formatter:off +// ReSharper disable All +#if NETCOREAPP3_0_OR_GREATER +// https://github.com/dotnet/runtime/issues/96197 +[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Runtime.CompilerServices.CallerArgumentExpressionAttribute))] +#else +#pragma warning disable +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Runtime.CompilerServices; + +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)] +internal sealed class CallerArgumentExpressionAttribute : Attribute +{ + public CallerArgumentExpressionAttribute(string parameterName) + { + ParameterName = parameterName; + } + + public string ParameterName { get; } +} +#endif diff --git a/src/OpenFeature.DependencyInjection/MultiTarget/IsExternalInit.cs b/src/OpenFeature.DependencyInjection/MultiTarget/IsExternalInit.cs new file mode 100644 index 00000000..87714111 --- /dev/null +++ b/src/OpenFeature.DependencyInjection/MultiTarget/IsExternalInit.cs @@ -0,0 +1,21 @@ +// @formatter:off +// ReSharper disable All +#if NET5_0_OR_GREATER +// https://github.com/dotnet/runtime/issues/96197 +[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Runtime.CompilerServices.IsExternalInit))] +#else +#pragma warning disable +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; + +namespace System.Runtime.CompilerServices; + +/// +/// Reserved to be used by the compiler for tracking metadata. +/// This class should not be used by developers in source code. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +static class IsExternalInit { } +#endif diff --git a/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj b/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj new file mode 100644 index 00000000..99270ab3 --- /dev/null +++ b/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj @@ -0,0 +1,24 @@ + + + + netstandard2.0;net8.0;net9.0;net462 + enable + enable + OpenFeature.DependencyInjection + + + + + + + + + + + + + + + + + diff --git a/src/OpenFeature.DependencyInjection/OpenFeatureBuilder.cs b/src/OpenFeature.DependencyInjection/OpenFeatureBuilder.cs new file mode 100644 index 00000000..ae1e8c8f --- /dev/null +++ b/src/OpenFeature.DependencyInjection/OpenFeatureBuilder.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace OpenFeature.DependencyInjection; + +/// +/// Describes a backed by an . +/// +/// The services being configured. +public class OpenFeatureBuilder(IServiceCollection services) +{ + /// The services being configured. + public IServiceCollection Services { get; } = services; + + /// + /// Indicates whether the evaluation context has been configured. + /// This property is used to determine if specific configurations or services + /// should be initialized based on the presence of an evaluation context. + /// + public bool IsContextConfigured { get; internal set; } + + /// + /// Indicates whether the policy has been configured. + /// + public bool IsPolicyConfigured { get; internal set; } + + /// + /// Gets a value indicating whether a default provider has been registered. + /// + public bool HasDefaultProvider { get; internal set; } + + /// + /// Gets the count of domain-bound providers that have been registered. + /// This count does not include the default provider. + /// + public int DomainBoundProviderRegistrationCount { get; internal set; } + + /// + /// Validates the current configuration, ensuring that a policy is set when multiple providers are registered + /// or when a default provider is registered alongside another provider. + /// + /// + /// Thrown if multiple providers are registered without a policy, or if both a default provider + /// and an additional provider are registered without a policy configuration. + /// + public void Validate() + { + if (!IsPolicyConfigured) + { + if (DomainBoundProviderRegistrationCount > 1) + { + throw new InvalidOperationException("Multiple providers have been registered, but no policy has been configured."); + } + + if (HasDefaultProvider && DomainBoundProviderRegistrationCount == 1) + { + throw new InvalidOperationException("A default provider and an additional provider have been registered without a policy configuration."); + } + } + } +} diff --git a/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs b/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs new file mode 100644 index 00000000..8f79f394 --- /dev/null +++ b/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs @@ -0,0 +1,306 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using OpenFeature.DependencyInjection; +using OpenFeature.Model; + +namespace OpenFeature; + +/// +/// Contains extension methods for the class. +/// +#if NET8_0_OR_GREATER +[System.Diagnostics.CodeAnalysis.Experimental(DependencyInjection.Diagnostics.FeatureCodes.NewDi)] +#endif +public static partial class OpenFeatureBuilderExtensions +{ + /// + /// This method is used to add a new context to the service collection. + /// + /// The instance. + /// the desired configuration + /// The instance. + /// Thrown when the or action is null. + public static OpenFeatureBuilder AddContext(this OpenFeatureBuilder builder, Action configure) + { + Guard.ThrowIfNull(builder); + Guard.ThrowIfNull(configure); + + return builder.AddContext((b, _) => configure(b)); + } + + /// + /// This method is used to add a new context to the service collection. + /// + /// The instance. + /// the desired configuration + /// The instance. + /// Thrown when the or action is null. + public static OpenFeatureBuilder AddContext(this OpenFeatureBuilder builder, Action configure) + { + Guard.ThrowIfNull(builder); + Guard.ThrowIfNull(configure); + + builder.IsContextConfigured = true; + builder.Services.TryAddTransient(provider => + { + var contextBuilder = EvaluationContext.Builder(); + configure(contextBuilder, provider); + return contextBuilder.Build(); + }); + + return builder; + } + + /// + /// Adds a feature provider using a factory method without additional configuration options. + /// This method adds the feature provider as a transient service and sets it as the default provider within the application. + /// + /// The used to configure feature flags. + /// + /// A factory method that creates and returns a + /// instance based on the provided service provider. + /// + /// The updated instance with the default feature provider set and configured. + /// Thrown if the is null, as a valid builder is required to add and configure providers. + public static OpenFeatureBuilder AddProvider(this OpenFeatureBuilder builder, Func implementationFactory) + => AddProvider(builder, implementationFactory, null); + + /// + /// Adds a feature provider using a factory method to create the provider instance and optionally configures its settings. + /// This method adds the feature provider as a transient service and sets it as the default provider within the application. + /// + /// Type derived from used to configure the feature provider. + /// The used to configure feature flags. + /// + /// A factory method that creates and returns a + /// instance based on the provided service provider. + /// + /// An optional delegate to configure the provider-specific options. + /// The updated instance with the default feature provider set and configured. + /// Thrown if the is null, as a valid builder is required to add and configure providers. + public static OpenFeatureBuilder AddProvider(this OpenFeatureBuilder builder, Func implementationFactory, Action? configureOptions) + where TOptions : OpenFeatureOptions + { + Guard.ThrowIfNull(builder); + + builder.HasDefaultProvider = true; + builder.Services.PostConfigure(options => options.AddDefaultProviderName()); + if (configureOptions != null) + { + builder.Services.Configure(configureOptions); + } + + builder.Services.TryAddTransient(implementationFactory); + builder.AddClient(); + return builder; + } + + /// + /// Adds a feature provider for a specific domain using provided options and a configuration builder. + /// + /// Type derived from used to configure the feature provider. + /// The used to configure feature flags. + /// The unique name of the provider. + /// + /// A factory method that creates a feature provider instance. + /// It adds the provider as a transient service unless it is already added. + /// + /// An optional delegate to configure the provider-specific options. + /// The updated instance with the new feature provider configured. + /// + /// Thrown if either or is null or if the is empty. + /// + public static OpenFeatureBuilder AddProvider(this OpenFeatureBuilder builder, string domain, Func implementationFactory, Action? configureOptions) + where TOptions : OpenFeatureOptions + { + Guard.ThrowIfNull(builder); + + builder.DomainBoundProviderRegistrationCount++; + + builder.Services.PostConfigure(options => options.AddProviderName(domain)); + if (configureOptions != null) + { + builder.Services.Configure(domain, configureOptions); + } + + builder.Services.TryAddKeyedTransient(domain, (provider, key) => + { + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + return implementationFactory(provider, key.ToString()!); + }); + + builder.AddClient(domain); + return builder; + } + + /// + /// Adds a feature provider for a specified domain using the default options. + /// This method configures a feature provider without custom options, delegating to the more generic AddProvider method. + /// + /// The used to configure feature flags. + /// The unique name of the provider. + /// + /// A factory method that creates a feature provider instance. + /// It adds the provider as a transient service unless it is already added. + /// + /// The updated instance with the new feature provider configured. + /// + /// Thrown if either or is null or if the is empty. + /// + public static OpenFeatureBuilder AddProvider(this OpenFeatureBuilder builder, string domain, Func implementationFactory) + => AddProvider(builder, domain, implementationFactory, configureOptions: null); + + /// + /// Adds a feature client to the service collection, configuring it to work with a specific context if provided. + /// + /// The instance. + /// Optional: The name for the feature client instance. + /// The instance. + internal static OpenFeatureBuilder AddClient(this OpenFeatureBuilder builder, string? name = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + if (builder.IsContextConfigured) + { + builder.Services.TryAddScoped(static provider => + { + var api = provider.GetRequiredService(); + var client = api.GetClient(); + var context = provider.GetRequiredService(); + client.SetContext(context); + return client; + }); + } + else + { + builder.Services.TryAddScoped(static provider => + { + var api = provider.GetRequiredService(); + return api.GetClient(); + }); + } + } + else + { + if (builder.IsContextConfigured) + { + builder.Services.TryAddKeyedScoped(name, static (provider, key) => + { + var api = provider.GetRequiredService(); + var client = api.GetClient(key!.ToString()); + var context = provider.GetRequiredService(); + client.SetContext(context); + return client; + }); + } + else + { + builder.Services.TryAddKeyedScoped(name, static (provider, key) => + { + var api = provider.GetRequiredService(); + return api.GetClient(key!.ToString()); + }); + } + } + + return builder; + } + + /// + /// Adds a default to the based on the policy name options. + /// This method configures the dependency injection container to resolve the appropriate + /// depending on the policy name selected. + /// If no name is selected (i.e., null), it retrieves the default client. + /// + /// The instance. + /// The configured instance. + internal static OpenFeatureBuilder AddPolicyBasedClient(this OpenFeatureBuilder builder) + { + builder.Services.AddScoped(provider => + { + var policy = provider.GetRequiredService>().Value; + var name = policy.DefaultNameSelector(provider); + if (name == null) + { + return provider.GetRequiredService(); + } + return provider.GetRequiredKeyedService(name); + }); + + return builder; + } + + /// + /// Configures policy name options for OpenFeature using the specified options type. + /// + /// The type of options used to configure . + /// The instance. + /// A delegate to configure . + /// The configured instance. + /// Thrown when the or is null. + public static OpenFeatureBuilder AddPolicyName(this OpenFeatureBuilder builder, Action configureOptions) + where TOptions : PolicyNameOptions + { + Guard.ThrowIfNull(builder); + Guard.ThrowIfNull(configureOptions); + + builder.IsPolicyConfigured = true; + + builder.Services.Configure(configureOptions); + return builder; + } + + /// + /// Configures the default policy name options for OpenFeature. + /// + /// The instance. + /// A delegate to configure . + /// The configured instance. + public static OpenFeatureBuilder AddPolicyName(this OpenFeatureBuilder builder, Action configureOptions) + => AddPolicyName(builder, configureOptions); + + /// + /// Adds a feature hook to the service collection using a factory method. Hooks added here are not domain-bound. + /// + /// The type of to be added. + /// The instance. + /// Optional factory for controlling how will be created in the DI container. + /// The instance. + public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder, Func? implementationFactory = null) + where THook : Hook + { + return builder.AddHook(typeof(THook).Name, implementationFactory); + } + + /// + /// Adds a feature hook to the service collection using a factory method and specified name. Hooks added here are not domain-bound. + /// + /// The type of to be added. + /// The instance. + /// The name of the that is being added. + /// Optional factory for controlling how will be created in the DI container. + /// The instance. + public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder, string hookName, Func? implementationFactory = null) + where THook : Hook + { + builder.Services.PostConfigure(options => options.AddHookName(hookName)); + + if (implementationFactory is not null) + { + builder.Services.TryAddKeyedSingleton(hookName, (serviceProvider, key) => + { + return implementationFactory(serviceProvider); + }); + } + else + { + builder.Services.TryAddKeyedSingleton(hookName); + } + + return builder; + } +} diff --git a/src/OpenFeature.DependencyInjection/OpenFeatureOptions.cs b/src/OpenFeature.DependencyInjection/OpenFeatureOptions.cs new file mode 100644 index 00000000..e9cc3cb1 --- /dev/null +++ b/src/OpenFeature.DependencyInjection/OpenFeatureOptions.cs @@ -0,0 +1,61 @@ +namespace OpenFeature.DependencyInjection; + +/// +/// Options to configure OpenFeature +/// +public class OpenFeatureOptions +{ + private readonly HashSet _providerNames = []; + + /// + /// Determines if a default provider has been registered. + /// + public bool HasDefaultProvider { get; private set; } + + /// + /// The type of the configured feature provider. + /// + public Type FeatureProviderType { get; protected internal set; } = null!; + + /// + /// Gets a read-only list of registered provider names. + /// + public IReadOnlyCollection ProviderNames => _providerNames; + + /// + /// Registers the default provider name if no specific name is provided. + /// Sets to true. + /// + protected internal void AddDefaultProviderName() => AddProviderName(null); + + /// + /// Registers a new feature provider name. This operation is thread-safe. + /// + /// The name of the feature provider to register. Registers as default if null. + protected internal void AddProviderName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + HasDefaultProvider = true; + } + else + { + lock (_providerNames) + { + _providerNames.Add(name!); + } + } + } + + private readonly HashSet _hookNames = []; + + internal IReadOnlyCollection HookNames => _hookNames; + + internal void AddHookName(string name) + { + lock (_hookNames) + { + _hookNames.Add(name); + } + } +} diff --git a/src/OpenFeature.DependencyInjection/OpenFeatureServiceCollectionExtensions.cs b/src/OpenFeature.DependencyInjection/OpenFeatureServiceCollectionExtensions.cs new file mode 100644 index 00000000..74d01ad3 --- /dev/null +++ b/src/OpenFeature.DependencyInjection/OpenFeatureServiceCollectionExtensions.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using OpenFeature.DependencyInjection; +using OpenFeature.DependencyInjection.Internal; + +namespace OpenFeature; + +/// +/// Contains extension methods for the class. +/// +public static partial class OpenFeatureServiceCollectionExtensions +{ + /// + /// Adds and configures OpenFeature services to the provided . + /// + /// The instance. + /// A configuration action for customizing OpenFeature setup via + /// The modified instance + /// Thrown if or is null. + public static IServiceCollection AddOpenFeature(this IServiceCollection services, Action configure) + { + Guard.ThrowIfNull(services); + Guard.ThrowIfNull(configure); + + // Register core OpenFeature services as singletons. + services.TryAddSingleton(Api.Instance); + services.TryAddSingleton(); + + var builder = new OpenFeatureBuilder(services); + configure(builder); + + // If a default provider is specified without additional providers, + // return early as no extra configuration is needed. + if (builder.HasDefaultProvider && builder.DomainBoundProviderRegistrationCount == 0) + { + return services; + } + + // Validate builder configuration to ensure consistency and required setup. + builder.Validate(); + + if (!builder.IsPolicyConfigured) + { + // Add a default name selector policy to use the first registered provider name as the default. + builder.AddPolicyName(options => + { + options.DefaultNameSelector = provider => + { + var options = provider.GetRequiredService>().Value; + return options.ProviderNames.First(); + }; + }); + } + + builder.AddPolicyBasedClient(); + return services; + } +} diff --git a/src/OpenFeature.DependencyInjection/PolicyNameOptions.cs b/src/OpenFeature.DependencyInjection/PolicyNameOptions.cs new file mode 100644 index 00000000..f77b019b --- /dev/null +++ b/src/OpenFeature.DependencyInjection/PolicyNameOptions.cs @@ -0,0 +1,12 @@ +namespace OpenFeature.DependencyInjection; + +/// +/// Options to configure the default feature client name. +/// +public class PolicyNameOptions +{ + /// + /// A delegate to select the default feature client name. + /// + public Func DefaultNameSelector { get; set; } = null!; +} diff --git a/src/OpenFeature.DependencyInjection/Providers/Memory/FeatureBuilderExtensions.cs b/src/OpenFeature.DependencyInjection/Providers/Memory/FeatureBuilderExtensions.cs new file mode 100644 index 00000000..d6346ad7 --- /dev/null +++ b/src/OpenFeature.DependencyInjection/Providers/Memory/FeatureBuilderExtensions.cs @@ -0,0 +1,126 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenFeature.Providers.Memory; + +namespace OpenFeature.DependencyInjection.Providers.Memory; + +/// +/// Extension methods for configuring feature providers with . +/// +#if NET8_0_OR_GREATER +[System.Diagnostics.CodeAnalysis.Experimental(Diagnostics.FeatureCodes.NewDi)] +#endif +public static partial class FeatureBuilderExtensions +{ + /// + /// Adds an in-memory feature provider to the with a factory for flags. + /// + /// The instance to configure. + /// + /// A factory function to provide an of flags. + /// If null, an empty provider will be created. + /// + /// The instance for chaining. + public static OpenFeatureBuilder AddInMemoryProvider(this OpenFeatureBuilder builder, Func?> flagsFactory) + => builder.AddProvider(provider => + { + var flags = flagsFactory(provider); + if (flags == null) + { + return new InMemoryProvider(); + } + + return new InMemoryProvider(flags); + }); + + /// + /// Adds an in-memory feature provider to the with a domain and factory for flags. + /// + /// The instance to configure. + /// The unique domain of the provider. + /// + /// A factory function to provide an of flags. + /// If null, an empty provider will be created. + /// + /// The instance for chaining. + public static OpenFeatureBuilder AddInMemoryProvider(this OpenFeatureBuilder builder, string domain, Func?> flagsFactory) + => AddInMemoryProvider(builder, domain, (provider, _) => flagsFactory(provider)); + + /// + /// Adds an in-memory feature provider to the with a domain and contextual flag factory. + /// If null, an empty provider will be created. + /// + /// The instance to configure. + /// The unique domain of the provider. + /// + /// A factory function to provide an of flags based on service provider and domain. + /// + /// The instance for chaining. + public static OpenFeatureBuilder AddInMemoryProvider(this OpenFeatureBuilder builder, string domain, Func?> flagsFactory) + => builder.AddProvider(domain, (provider, key) => + { + var flags = flagsFactory(provider, key); + if (flags == null) + { + return new InMemoryProvider(); + } + + return new InMemoryProvider(flags); + }); + + /// + /// Adds an in-memory feature provider to the with optional flag configuration. + /// + /// The instance to configure. + /// + /// An optional delegate to configure feature flags in the in-memory provider. + /// If null, an empty provider will be created. + /// + /// The instance for chaining. + public static OpenFeatureBuilder AddInMemoryProvider(this OpenFeatureBuilder builder, Action>? configure = null) + => builder.AddProvider(CreateProvider, options => ConfigureFlags(options, configure)); + + /// + /// Adds an in-memory feature provider with a specific domain to the with optional flag configuration. + /// + /// The instance to configure. + /// The unique domain of the provider + /// + /// An optional delegate to configure feature flags in the in-memory provider. + /// If null, an empty provider will be created. + /// + /// The instance for chaining. + public static OpenFeatureBuilder AddInMemoryProvider(this OpenFeatureBuilder builder, string domain, Action>? configure = null) + => builder.AddProvider(domain, CreateProvider, options => ConfigureFlags(options, configure)); + + private static FeatureProvider CreateProvider(IServiceProvider provider, string domain) + { + var options = provider.GetRequiredService>().Get(domain); + if (options.Flags == null) + { + return new InMemoryProvider(); + } + + return new InMemoryProvider(options.Flags); + } + + private static FeatureProvider CreateProvider(IServiceProvider provider) + { + var options = provider.GetRequiredService>().Value; + if (options.Flags == null) + { + return new InMemoryProvider(); + } + + return new InMemoryProvider(options.Flags); + } + + private static void ConfigureFlags(InMemoryProviderOptions options, Action>? configure) + { + if (configure != null) + { + options.Flags = new Dictionary(); + configure.Invoke(options.Flags); + } + } +} diff --git a/src/OpenFeature.DependencyInjection/Providers/Memory/InMemoryProviderOptions.cs b/src/OpenFeature.DependencyInjection/Providers/Memory/InMemoryProviderOptions.cs new file mode 100644 index 00000000..ea5433f4 --- /dev/null +++ b/src/OpenFeature.DependencyInjection/Providers/Memory/InMemoryProviderOptions.cs @@ -0,0 +1,19 @@ +using OpenFeature.Providers.Memory; + +namespace OpenFeature.DependencyInjection.Providers.Memory; + +/// +/// Options for configuring the in-memory feature flag provider. +/// +public class InMemoryProviderOptions : OpenFeatureOptions +{ + /// + /// Gets or sets the feature flags to be used by the in-memory provider. + /// + /// + /// This property allows you to specify a dictionary of flags where the key is the flag name + /// and the value is the corresponding instance. + /// If no flags are provided, the in-memory provider will start with an empty set of flags. + /// + public IDictionary? Flags { get; set; } +} diff --git a/src/OpenFeature.Hosting/FeatureLifecycleStateOptions.cs b/src/OpenFeature.Hosting/FeatureLifecycleStateOptions.cs new file mode 100644 index 00000000..4e3c1c33 --- /dev/null +++ b/src/OpenFeature.Hosting/FeatureLifecycleStateOptions.cs @@ -0,0 +1,18 @@ +namespace OpenFeature; + +/// +/// Represents the lifecycle state options for a feature, +/// defining the states during the start and stop lifecycle. +/// +public class FeatureLifecycleStateOptions +{ + /// + /// Gets or sets the state during the feature startup lifecycle. + /// + public FeatureStartState StartState { get; set; } = FeatureStartState.Starting; + + /// + /// Gets or sets the state during the feature shutdown lifecycle. + /// + public FeatureStopState StopState { get; set; } = FeatureStopState.Stopped; +} diff --git a/src/OpenFeature.Hosting/FeatureStartState.cs b/src/OpenFeature.Hosting/FeatureStartState.cs new file mode 100644 index 00000000..8001b9c2 --- /dev/null +++ b/src/OpenFeature.Hosting/FeatureStartState.cs @@ -0,0 +1,22 @@ +namespace OpenFeature; + +/// +/// Defines the various states for starting a feature. +/// +public enum FeatureStartState +{ + /// + /// The feature is in the process of starting. + /// + Starting, + + /// + /// The feature is at the start state. + /// + Start, + + /// + /// The feature has fully started. + /// + Started +} diff --git a/src/OpenFeature.Hosting/FeatureStopState.cs b/src/OpenFeature.Hosting/FeatureStopState.cs new file mode 100644 index 00000000..d8d6a28c --- /dev/null +++ b/src/OpenFeature.Hosting/FeatureStopState.cs @@ -0,0 +1,22 @@ +namespace OpenFeature; + +/// +/// Defines the various states for stopping a feature. +/// +public enum FeatureStopState +{ + /// + /// The feature is in the process of stopping. + /// + Stopping, + + /// + /// The feature is at the stop state. + /// + Stop, + + /// + /// The feature has fully stopped. + /// + Stopped +} diff --git a/src/OpenFeature.Hosting/HostedFeatureLifecycleService.cs b/src/OpenFeature.Hosting/HostedFeatureLifecycleService.cs new file mode 100644 index 00000000..5209a525 --- /dev/null +++ b/src/OpenFeature.Hosting/HostedFeatureLifecycleService.cs @@ -0,0 +1,100 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OpenFeature.DependencyInjection; + +namespace OpenFeature.Hosting; + +/// +/// A hosted service that manages the lifecycle of features within the application. +/// It ensures that features are properly initialized when the service starts +/// and gracefully shuts down when the service stops. +/// +public sealed partial class HostedFeatureLifecycleService : IHostedLifecycleService +{ + private readonly ILogger _logger; + private readonly IFeatureLifecycleManager _featureLifecycleManager; + private readonly IOptions _featureLifecycleStateOptions; + + /// + /// Initializes a new instance of the class. + /// + /// The logger used to log lifecycle events. + /// The feature lifecycle manager responsible for initialization and shutdown. + /// Options that define the start and stop states of the feature lifecycle. + public HostedFeatureLifecycleService( + ILogger logger, + IFeatureLifecycleManager featureLifecycleManager, + IOptions featureLifecycleStateOptions) + { + _logger = logger; + _featureLifecycleManager = featureLifecycleManager; + _featureLifecycleStateOptions = featureLifecycleStateOptions; + } + + /// + /// Ensures that the feature is properly initialized when the service starts. + /// + public async Task StartingAsync(CancellationToken cancellationToken) + => await InitializeIfStateMatchesAsync(FeatureStartState.Starting, cancellationToken).ConfigureAwait(false); + + /// + /// Ensures that the feature is in the "Start" state. + /// + public async Task StartAsync(CancellationToken cancellationToken) + => await InitializeIfStateMatchesAsync(FeatureStartState.Start, cancellationToken).ConfigureAwait(false); + + /// + /// Ensures that the feature is fully started and operational. + /// + public async Task StartedAsync(CancellationToken cancellationToken) + => await InitializeIfStateMatchesAsync(FeatureStartState.Started, cancellationToken).ConfigureAwait(false); + + /// + /// Gracefully shuts down the feature when the service is stopping. + /// + public async Task StoppingAsync(CancellationToken cancellationToken) + => await ShutdownIfStateMatchesAsync(FeatureStopState.Stopping, cancellationToken).ConfigureAwait(false); + + /// + /// Ensures that the feature is in the "Stop" state. + /// + public async Task StopAsync(CancellationToken cancellationToken) + => await ShutdownIfStateMatchesAsync(FeatureStopState.Stop, cancellationToken).ConfigureAwait(false); + + /// + /// Ensures that the feature is fully stopped and no longer operational. + /// + public async Task StoppedAsync(CancellationToken cancellationToken) + => await ShutdownIfStateMatchesAsync(FeatureStopState.Stopped, cancellationToken).ConfigureAwait(false); + + /// + /// Initializes the feature lifecycle if the current state matches the expected start state. + /// + private async Task InitializeIfStateMatchesAsync(FeatureStartState expectedState, CancellationToken cancellationToken) + { + if (_featureLifecycleStateOptions.Value.StartState == expectedState) + { + this.LogInitializingFeatureLifecycleManager(expectedState); + await _featureLifecycleManager.EnsureInitializedAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Shuts down the feature lifecycle if the current state matches the expected stop state. + /// + private async Task ShutdownIfStateMatchesAsync(FeatureStopState expectedState, CancellationToken cancellationToken) + { + if (_featureLifecycleStateOptions.Value.StopState == expectedState) + { + this.LogShuttingDownFeatureLifecycleManager(expectedState); + await _featureLifecycleManager.ShutdownAsync(cancellationToken).ConfigureAwait(false); + } + } + + [LoggerMessage(200, LogLevel.Information, "Initializing the Feature Lifecycle Manager for state {State}.")] + partial void LogInitializingFeatureLifecycleManager(FeatureStartState state); + + [LoggerMessage(200, LogLevel.Information, "Shutting down the Feature Lifecycle Manager for state {State}")] + partial void LogShuttingDownFeatureLifecycleManager(FeatureStopState state); +} diff --git a/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj b/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj new file mode 100644 index 00000000..43237e0f --- /dev/null +++ b/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj @@ -0,0 +1,18 @@ + + + + net8.0;net9.0 + enable + enable + OpenFeature + + + + + + + + + + + diff --git a/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs b/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs new file mode 100644 index 00000000..80e760d9 --- /dev/null +++ b/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.DependencyInjection; +using OpenFeature.DependencyInjection; +using OpenFeature.Hosting; + +namespace OpenFeature; + +/// +/// Extension methods for configuring the hosted feature lifecycle in the . +/// +public static partial class OpenFeatureBuilderExtensions +{ + /// + /// Adds the to the OpenFeatureBuilder, + /// which manages the lifecycle of features within the application. It also allows + /// configuration of the . + /// + /// The instance. + /// An optional action to configure . + /// The instance. + public static OpenFeatureBuilder AddHostedFeatureLifecycle(this OpenFeatureBuilder builder, Action? configureOptions = null) + { + if (configureOptions is not null) + { + builder.Services.Configure(configureOptions); + } + + builder.Services.AddHostedService(); + return builder; + } +} diff --git a/src/OpenFeature/Api.cs b/src/OpenFeature/Api.cs new file mode 100644 index 00000000..cc0161c1 --- /dev/null +++ b/src/OpenFeature/Api.cs @@ -0,0 +1,368 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using OpenFeature.Constant; +using OpenFeature.Error; +using OpenFeature.Model; + +namespace OpenFeature; + +/// +/// The evaluation API allows for the evaluation of feature flag values, independent of any flag control plane or vendor. +/// In the absence of a provider the evaluation API uses the "No-op provider", which simply returns the supplied default flag value. +/// +/// +public sealed class Api : IEventBus +{ + private EvaluationContext _evaluationContext = EvaluationContext.Empty; + private EventExecutor _eventExecutor = new EventExecutor(); + private ProviderRepository _repository = new ProviderRepository(); + private readonly ConcurrentStack _hooks = new ConcurrentStack(); + private ITransactionContextPropagator _transactionContextPropagator = new NoOpTransactionContextPropagator(); + private readonly object _transactionContextPropagatorLock = new(); + + /// The reader/writer locks are not disposed because the singleton instance should never be disposed. + private readonly ReaderWriterLockSlim _evaluationContextLock = new ReaderWriterLockSlim(); + + /// + /// Singleton instance of Api + /// + public static Api Instance { get; private set; } = new Api(); + + // Explicit static constructor to tell C# compiler + // not to mark type as beforeFieldInit + // IE Lazy way of ensuring this is thread safe without using locks + static Api() { } + private Api() { } + + /// + /// Sets the default feature provider. In order to wait for the provider to be set, and initialization to complete, + /// await the returned task. + /// + /// The provider cannot be set to null. Attempting to set the provider to null has no effect. May throw an exception if cannot be initialized. + /// Implementation of + public async Task SetProviderAsync(FeatureProvider featureProvider) + { + this._eventExecutor.RegisterDefaultFeatureProvider(featureProvider); + await this._repository.SetProviderAsync(featureProvider, this.GetContext(), this.AfterInitialization, this.AfterError).ConfigureAwait(false); + + } + + /// + /// Binds the feature provider to the given domain. In order to wait for the provider to be set, and + /// initialization to complete, await the returned task. + /// + /// The provider cannot be set to null. Attempting to set the provider to null has no effect. May throw an exception if cannot be initialized. + /// An identifier which logically binds clients with providers + /// Implementation of + /// domain cannot be null or empty + public async Task SetProviderAsync(string domain, FeatureProvider featureProvider) + { + if (string.IsNullOrWhiteSpace(domain)) + { + throw new ArgumentNullException(nameof(domain)); + } + this._eventExecutor.RegisterClientFeatureProvider(domain, featureProvider); + await this._repository.SetProviderAsync(domain, featureProvider, this.GetContext(), this.AfterInitialization, this.AfterError).ConfigureAwait(false); + } + + /// + /// Gets the feature provider + /// + /// The feature provider may be set from multiple threads, when accessing the global feature provider + /// it should be accessed once for an operation, and then that reference should be used for all dependent + /// operations. For instance, during an evaluation the flag resolution method, and the provider hooks + /// should be accessed from the same reference, not two independent calls to + /// . + /// + /// + /// + public FeatureProvider GetProvider() + { + return this._repository.GetProvider(); + } + + /// + /// Gets the feature provider with given domain + /// + /// An identifier which logically binds clients with providers + /// A provider associated with the given domain, if domain is empty or doesn't + /// have a corresponding provider the default provider will be returned + public FeatureProvider GetProvider(string domain) + { + return this._repository.GetProvider(domain); + } + + /// + /// Gets providers metadata + /// + /// This method is not guaranteed to return the same provider instance that may be used during an evaluation + /// in the case where the provider may be changed from another thread. + /// For multiple dependent provider operations see . + /// + /// + /// + public Metadata? GetProviderMetadata() => this.GetProvider().GetMetadata(); + + /// + /// Gets providers metadata assigned to the given domain. If the domain has no provider + /// assigned to it the default provider will be returned + /// + /// An identifier which logically binds clients with providers + /// Metadata assigned to provider + public Metadata? GetProviderMetadata(string domain) => this.GetProvider(domain).GetMetadata(); + + /// + /// Create a new instance of using the current provider + /// + /// Name of client + /// Version of client + /// Logger instance used by client + /// Context given to this client + /// + public FeatureClient GetClient(string? name = null, string? version = null, ILogger? logger = null, + EvaluationContext? context = null) => + new FeatureClient(() => this._repository.GetProvider(name), name, version, logger, context); + + /// + /// Appends list of hooks to global hooks list + /// + /// The appending operation will be atomic. + /// + /// + /// A list of + public void AddHooks(IEnumerable hooks) +#if NET7_0_OR_GREATER + => this._hooks.PushRange(hooks as Hook[] ?? hooks.ToArray()); +#else + { + // See: https://github.com/dotnet/runtime/issues/62121 + if (hooks is Hook[] array) + { + if (array.Length > 0) + this._hooks.PushRange(array); + + return; + } + + array = hooks.ToArray(); + + if (array.Length > 0) + this._hooks.PushRange(array); + } +#endif + + /// + /// Adds a hook to global hooks list + /// + /// Hooks which are dependent on each other should be provided in a collection + /// using the . + /// + /// + /// Hook that implements the interface + public void AddHooks(Hook hook) => this._hooks.Push(hook); + + /// + /// Enumerates the global hooks. + /// + /// The items enumerated will reflect the registered hooks + /// at the start of enumeration. Hooks added during enumeration + /// will not be included. + /// + /// + /// Enumeration of + public IEnumerable GetHooks() => this._hooks.Reverse(); + + /// + /// Removes all hooks from global hooks list + /// + public void ClearHooks() => this._hooks.Clear(); + + /// + /// Sets the global + /// + /// The to set + public void SetContext(EvaluationContext? context) + { + this._evaluationContextLock.EnterWriteLock(); + try + { + this._evaluationContext = context ?? EvaluationContext.Empty; + } + finally + { + this._evaluationContextLock.ExitWriteLock(); + } + } + + /// + /// Gets the global + /// + /// The evaluation context may be set from multiple threads, when accessing the global evaluation context + /// it should be accessed once for an operation, and then that reference should be used for all dependent + /// operations. + /// + /// + /// An + public EvaluationContext GetContext() + { + this._evaluationContextLock.EnterReadLock(); + try + { + return this._evaluationContext; + } + finally + { + this._evaluationContextLock.ExitReadLock(); + } + } + + /// + /// Return the transaction context propagator. + /// + /// the registered transaction context propagator + internal ITransactionContextPropagator GetTransactionContextPropagator() + { + return this._transactionContextPropagator; + } + + /// + /// Sets the transaction context propagator. + /// + /// the transaction context propagator to be registered + /// Transaction context propagator cannot be null + public void SetTransactionContextPropagator(ITransactionContextPropagator transactionContextPropagator) + { + if (transactionContextPropagator == null) + { + throw new ArgumentNullException(nameof(transactionContextPropagator), + "Transaction context propagator cannot be null"); + } + + lock (this._transactionContextPropagatorLock) + { + this._transactionContextPropagator = transactionContextPropagator; + } + } + + /// + /// Returns the currently defined transaction context using the registered transaction context propagator. + /// + /// The current transaction context + public EvaluationContext GetTransactionContext() + { + return this._transactionContextPropagator.GetTransactionContext(); + } + + /// + /// Sets the transaction context using the registered transaction context propagator. + /// + /// The to set + /// Transaction context propagator is not set. + /// Evaluation context cannot be null + public void SetTransactionContext(EvaluationContext evaluationContext) + { + if (evaluationContext == null) + { + throw new ArgumentNullException(nameof(evaluationContext), "Evaluation context cannot be null"); + } + + this._transactionContextPropagator.SetTransactionContext(evaluationContext); + } + + /// + /// + /// Shut down and reset the current status of OpenFeature API. + /// + /// + /// This call cleans up all active providers and attempts to shut down internal event handling mechanisms. + /// Once shut down is complete, API is reset and ready to use again. + /// + /// + public async Task ShutdownAsync() + { + await using (this._eventExecutor.ConfigureAwait(false)) + await using (this._repository.ConfigureAwait(false)) + { + this._evaluationContext = EvaluationContext.Empty; + this._hooks.Clear(); + this._transactionContextPropagator = new NoOpTransactionContextPropagator(); + + // TODO: make these lazy to avoid extra allocations on the common cleanup path? + this._eventExecutor = new EventExecutor(); + this._repository = new ProviderRepository(); + } + } + + /// + public void AddHandler(ProviderEventTypes type, EventHandlerDelegate handler) + { + this._eventExecutor.AddApiLevelHandler(type, handler); + } + + /// + public void RemoveHandler(ProviderEventTypes type, EventHandlerDelegate handler) + { + this._eventExecutor.RemoveApiLevelHandler(type, handler); + } + + /// + /// Sets the logger for the API + /// + /// The logger to be used + public void SetLogger(ILogger logger) + { + this._eventExecutor.SetLogger(logger); + this._repository.SetLogger(logger); + } + + internal void AddClientHandler(string client, ProviderEventTypes eventType, EventHandlerDelegate handler) + => this._eventExecutor.AddClientHandler(client, eventType, handler); + + internal void RemoveClientHandler(string client, ProviderEventTypes eventType, EventHandlerDelegate handler) + => this._eventExecutor.RemoveClientHandler(client, eventType, handler); + + /// + /// Update the provider state to READY and emit a READY event after successful init. + /// + private async Task AfterInitialization(FeatureProvider provider) + { + provider.Status = ProviderStatus.Ready; + var eventPayload = new ProviderEventPayload + { + Type = ProviderEventTypes.ProviderReady, + Message = "Provider initialization complete", + ProviderName = provider.GetMetadata()?.Name, + }; + + await this._eventExecutor.EventChannel.Writer.WriteAsync(new Event { Provider = provider, EventPayload = eventPayload }).ConfigureAwait(false); + } + + /// + /// Update the provider state to ERROR and emit an ERROR after failed init. + /// + private async Task AfterError(FeatureProvider provider, Exception? ex) + { + provider.Status = typeof(ProviderFatalException) == ex?.GetType() ? ProviderStatus.Fatal : ProviderStatus.Error; + var eventPayload = new ProviderEventPayload + { + Type = ProviderEventTypes.ProviderError, + Message = $"Provider initialization error: {ex?.Message}", + ProviderName = provider.GetMetadata()?.Name, + }; + + await this._eventExecutor.EventChannel.Writer.WriteAsync(new Event { Provider = provider, EventPayload = eventPayload }).ConfigureAwait(false); + } + + /// + /// This method should only be using for testing purposes. It will reset the singleton instance of the API. + /// + internal static void ResetApi() + { + Instance = new Api(); + } +} diff --git a/src/OpenFeature/AsyncLocalTransactionContextPropagator.cs b/src/OpenFeature/AsyncLocalTransactionContextPropagator.cs new file mode 100644 index 00000000..86992aac --- /dev/null +++ b/src/OpenFeature/AsyncLocalTransactionContextPropagator.cs @@ -0,0 +1,25 @@ +using System.Threading; +using OpenFeature.Model; + +namespace OpenFeature; + +/// +/// This is a task transaction context implementation of +/// It uses the to store the transaction context. +/// +public sealed class AsyncLocalTransactionContextPropagator : ITransactionContextPropagator +{ + private readonly AsyncLocal _transactionContext = new(); + + /// + public EvaluationContext GetTransactionContext() + { + return this._transactionContext.Value ?? EvaluationContext.Empty; + } + + /// + public void SetTransactionContext(EvaluationContext evaluationContext) + { + this._transactionContext.Value = evaluationContext; + } +} diff --git a/src/OpenFeature/Constant/Constants.cs b/src/OpenFeature/Constant/Constants.cs new file mode 100644 index 00000000..319844b8 --- /dev/null +++ b/src/OpenFeature/Constant/Constants.cs @@ -0,0 +1,8 @@ +namespace OpenFeature.Constant; + +internal static class NoOpProvider +{ + public const string NoOpProviderName = "No-op Provider"; + public const string ReasonNoOp = "No-op"; + public const string Variant = "No-op"; +} diff --git a/src/OpenFeature/Constant/ErrorType.cs b/src/OpenFeature/Constant/ErrorType.cs index 936db1f6..d36f3d96 100644 --- a/src/OpenFeature/Constant/ErrorType.cs +++ b/src/OpenFeature/Constant/ErrorType.cs @@ -1,41 +1,55 @@ using System.ComponentModel; -namespace OpenFeature.SDK.Constant +namespace OpenFeature.Constant; + +/// +/// These errors are used to indicate abnormal execution when evaluation a flag +/// +/// +public enum ErrorType { /// - /// These errors are used to indicate abnormal execution when evaluation a flag - /// - /// - public enum ErrorType - { - /// - /// Default value, no error occured - /// - None, - - /// - /// Provider has yet been initialized - /// - [Description("PROVIDER_NOT_READY")] ProviderNotReady, - - /// - /// Provider was unable to find the flag - /// - [Description("FLAG_NOT_FOUND")] FlagNotFound, - - /// - /// Provider failed to parse the flag response - /// - [Description("PARSE_ERROR")] ParseError, - - /// - /// Request type does not match the expected type - /// - [Description("TYPE_MISMATCH")] TypeMismatch, - - /// - /// Abnormal execution of the provider - /// - [Description("GENERAL")] General - } + /// Default value, no error occured + /// + None, + + /// + /// Provider has yet been initialized + /// + [Description("PROVIDER_NOT_READY")] ProviderNotReady, + + /// + /// Provider was unable to find the flag + /// + [Description("FLAG_NOT_FOUND")] FlagNotFound, + + /// + /// Provider failed to parse the flag response + /// + [Description("PARSE_ERROR")] ParseError, + + /// + /// Request type does not match the expected type + /// + [Description("TYPE_MISMATCH")] TypeMismatch, + + /// + /// Abnormal execution of the provider + /// + [Description("GENERAL")] General, + + /// + /// Context does not satisfy provider requirements. + /// + [Description("INVALID_CONTEXT")] InvalidContext, + + /// + /// Context does not contain a targeting key and the provider requires one. + /// + [Description("TARGETING_KEY_MISSING")] TargetingKeyMissing, + + /// + /// The provider has entered an irrecoverable error state. + /// + [Description("PROVIDER_FATAL")] ProviderFatal, } diff --git a/src/OpenFeature/Constant/EventType.cs b/src/OpenFeature/Constant/EventType.cs new file mode 100644 index 00000000..369c10b2 --- /dev/null +++ b/src/OpenFeature/Constant/EventType.cs @@ -0,0 +1,24 @@ +namespace OpenFeature.Constant; + +/// +/// The ProviderEventTypes enum represents the available event types of a provider. +/// +public enum ProviderEventTypes +{ + /// + /// ProviderReady should be emitted by a provider upon completing its initialisation. + /// + ProviderReady, + /// + /// ProviderError should be emitted by a provider upon encountering an error. + /// + ProviderError, + /// + /// ProviderConfigurationChanged should be emitted by a provider when a flag configuration has been changed. + /// + ProviderConfigurationChanged, + /// + /// ProviderStale should be emitted by a provider when it goes into the stale state. + /// + ProviderStale +} diff --git a/src/OpenFeature/Constant/FlagValueType.cs b/src/OpenFeature/Constant/FlagValueType.cs index 26b29921..d63db712 100644 --- a/src/OpenFeature/Constant/FlagValueType.cs +++ b/src/OpenFeature/Constant/FlagValueType.cs @@ -1,28 +1,27 @@ -namespace OpenFeature.SDK.Constant +namespace OpenFeature.Constant; + +/// +/// Used to identity what object type of flag being evaluated +/// +public enum FlagValueType { /// - /// Used to identity what object type of flag being evaluated + /// Flag is a boolean value /// - public enum FlagValueType - { - /// - /// Flag is a boolean value - /// - Boolean, + Boolean, - /// - /// Flag is a string value - /// - String, + /// + /// Flag is a string value + /// + String, - /// - /// Flag is a numeric value - /// - Number, + /// + /// Flag is a numeric value + /// + Number, - /// - /// Flag is a structured value - /// - Object - } + /// + /// Flag is a structured value + /// + Object } diff --git a/src/OpenFeature/Constant/NoOpProvider.cs b/src/OpenFeature/Constant/NoOpProvider.cs deleted file mode 100644 index 210ac24d..00000000 --- a/src/OpenFeature/Constant/NoOpProvider.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace OpenFeature.SDK.Constant -{ - internal static class NoOpProvider - { - public const string NoOpProviderName = "No-op Provider"; - public const string ReasonNoOp = "No-op"; - public const string Variant = "No-op"; - } -} diff --git a/src/OpenFeature/Constant/ProviderStatus.cs b/src/OpenFeature/Constant/ProviderStatus.cs new file mode 100644 index 00000000..76033746 --- /dev/null +++ b/src/OpenFeature/Constant/ProviderStatus.cs @@ -0,0 +1,35 @@ +using System.ComponentModel; + +namespace OpenFeature.Constant; + +/// +/// The state of the provider. +/// +/// +public enum ProviderStatus +{ + /// + /// The provider has not been initialized and cannot yet evaluate flags. + /// + [Description("NOT_READY")] NotReady, + + /// + /// The provider is ready to resolve flags. + /// + [Description("READY")] Ready, + + /// + /// The provider's cached state is no longer valid and may not be up-to-date with the source of truth. + /// + [Description("STALE")] Stale, + + /// + /// The provider is in an error state and unable to evaluate flags. + /// + [Description("ERROR")] Error, + + /// + /// The provider has entered an irrecoverable error state. + /// + [Description("FATAL")] Fatal, +} diff --git a/src/OpenFeature/Constant/Reason.cs b/src/OpenFeature/Constant/Reason.cs index 70d0c177..bd0653b5 100644 --- a/src/OpenFeature/Constant/Reason.cs +++ b/src/OpenFeature/Constant/Reason.cs @@ -1,40 +1,49 @@ -namespace OpenFeature.SDK.Constant +namespace OpenFeature.Constant; + +/// +/// Common reasons used during flag resolution +/// +/// Reason Specification +public static class Reason { /// - /// Common reasons used during flag resolution - /// - /// Reason Specification - public static class Reason - { - /// - /// Use when the flag is matched based on the evaluation context user data - /// - public static string TargetingMatch = "TARGETING_MATCH"; - - /// - /// Use when the flag is matched based on a split rule in the feature flag provider - /// - public static string Split = "SPLIT"; - - /// - /// Use when the flag is disabled in the feature flag provider - /// - public static string Disabled = "DISABLED"; - - /// - /// Default reason when evaluating flag - /// - public static string Default = "DEFAULT"; - - /// - /// Use when an unknown reason is encountered when evaluating flag. - /// An example of this is if the feature provider returns a reason that is not defined in the spec - /// - public static string Unknown = "UNKNOWN"; - - /// - /// Use this flag when abnormal execution is encountered. - /// - public static string Error = "ERROR"; - } + /// Use when the flag is matched based on the evaluation context user data + /// + public const string TargetingMatch = "TARGETING_MATCH"; + + /// + /// Use when the flag is matched based on a split rule in the feature flag provider + /// + public const string Split = "SPLIT"; + + /// + /// Use when the flag is disabled in the feature flag provider + /// + public const string Disabled = "DISABLED"; + + /// + /// Default reason when evaluating flag + /// + public const string Default = "DEFAULT"; + + /// + /// The resolved value is static (no dynamic evaluation) + /// + public const string Static = "STATIC"; + + /// + /// The resolved value was retrieved from cache + /// + public const string Cached = "CACHED"; + + /// + /// Use when an unknown reason is encountered when evaluating flag. + /// An example of this is if the feature provider returns a reason that is not defined in the spec + /// + public const string Unknown = "UNKNOWN"; + + /// + /// Use this flag when abnormal execution is encountered. + /// + public const string Error = "ERROR"; } diff --git a/src/OpenFeature/Error/FeatureProviderException.cs b/src/OpenFeature/Error/FeatureProviderException.cs index dc3368bb..b0431ab7 100644 --- a/src/OpenFeature/Error/FeatureProviderException.cs +++ b/src/OpenFeature/Error/FeatureProviderException.cs @@ -1,42 +1,28 @@ using System; -using OpenFeature.SDK.Constant; -using OpenFeature.SDK.Extension; +using OpenFeature.Constant; -namespace OpenFeature.SDK.Error +namespace OpenFeature.Error; + +/// +/// Used to represent an abnormal error when evaluating a flag. +/// This exception should be thrown when evaluating a flag inside a IFeatureFlag provider +/// +public class FeatureProviderException : Exception { /// - /// Used to represent an abnormal error when evaluating a flag. This exception should be thrown - /// when evaluating a flag inside a IFeatureFlag provider + /// Error that occurred during evaluation /// - public class FeatureProviderException : Exception - { - /// - /// Description of error that occured when evaluating a flag - /// - public string ErrorDescription { get; } - - /// - /// Initialize a new instance of the class - /// - /// Common error types - /// Exception message - /// Optional inner exception - public FeatureProviderException(ErrorType errorType, string message = null, Exception innerException = null) - : base(message, innerException) - { - this.ErrorDescription = errorType.GetDescription(); - } + public ErrorType ErrorType { get; } - /// - /// Initialize a new instance of the class - /// - /// A string representation describing the error that occured - /// Exception message - /// Optional inner exception - public FeatureProviderException(string errorCode, string message = null, Exception innerException = null) - : base(message, innerException) - { - this.ErrorDescription = errorCode; - } + /// + /// Initialize a new instance of the class + /// + /// Common error types + /// Exception message + /// Optional inner exception + public FeatureProviderException(ErrorType errorType, string? message = null, Exception? innerException = null) + : base(message, innerException) + { + this.ErrorType = errorType; } } diff --git a/src/OpenFeature/Error/FlagNotFoundException.cs b/src/OpenFeature/Error/FlagNotFoundException.cs new file mode 100644 index 00000000..d685bb4a --- /dev/null +++ b/src/OpenFeature/Error/FlagNotFoundException.cs @@ -0,0 +1,22 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using OpenFeature.Constant; + +namespace OpenFeature.Error; + +/// +/// Provider was unable to find the flag error when evaluating a flag. +/// +[ExcludeFromCodeCoverage] +public class FlagNotFoundException : FeatureProviderException +{ + /// + /// Initialize a new instance of the class + /// + /// Exception message + /// Optional inner exception + public FlagNotFoundException(string? message = null, Exception? innerException = null) + : base(ErrorType.FlagNotFound, message, innerException) + { + } +} diff --git a/src/OpenFeature/Error/GeneralException.cs b/src/OpenFeature/Error/GeneralException.cs new file mode 100644 index 00000000..0f9da24c --- /dev/null +++ b/src/OpenFeature/Error/GeneralException.cs @@ -0,0 +1,22 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using OpenFeature.Constant; + +namespace OpenFeature.Error; + +/// +/// Abnormal execution of the provider when evaluating a flag. +/// +[ExcludeFromCodeCoverage] +public class GeneralException : FeatureProviderException +{ + /// + /// Initialize a new instance of the class + /// + /// Exception message + /// Optional inner exception + public GeneralException(string? message = null, Exception? innerException = null) + : base(ErrorType.General, message, innerException) + { + } +} diff --git a/src/OpenFeature/Error/InvalidContextException.cs b/src/OpenFeature/Error/InvalidContextException.cs new file mode 100644 index 00000000..881d0464 --- /dev/null +++ b/src/OpenFeature/Error/InvalidContextException.cs @@ -0,0 +1,22 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using OpenFeature.Constant; + +namespace OpenFeature.Error; + +/// +/// Context does not satisfy provider requirements when evaluating a flag. +/// +[ExcludeFromCodeCoverage] +public class InvalidContextException : FeatureProviderException +{ + /// + /// Initialize a new instance of the class + /// + /// Exception message + /// Optional inner exception + public InvalidContextException(string? message = null, Exception? innerException = null) + : base(ErrorType.InvalidContext, message, innerException) + { + } +} diff --git a/src/OpenFeature/Error/ParseErrorException.cs b/src/OpenFeature/Error/ParseErrorException.cs new file mode 100644 index 00000000..57bcf271 --- /dev/null +++ b/src/OpenFeature/Error/ParseErrorException.cs @@ -0,0 +1,22 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using OpenFeature.Constant; + +namespace OpenFeature.Error; + +/// +/// Provider failed to parse the flag response when evaluating a flag. +/// +[ExcludeFromCodeCoverage] +public class ParseErrorException : FeatureProviderException +{ + /// + /// Initialize a new instance of the class + /// + /// Exception message + /// Optional inner exception + public ParseErrorException(string? message = null, Exception? innerException = null) + : base(ErrorType.ParseError, message, innerException) + { + } +} diff --git a/src/OpenFeature/Error/ProviderFatalException.cs b/src/OpenFeature/Error/ProviderFatalException.cs new file mode 100644 index 00000000..60ba5f25 --- /dev/null +++ b/src/OpenFeature/Error/ProviderFatalException.cs @@ -0,0 +1,22 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using OpenFeature.Constant; + +namespace OpenFeature.Error; + +/// +/// An exception that signals the provider has entered an irrecoverable error state. +/// +[ExcludeFromCodeCoverage] +public class ProviderFatalException : FeatureProviderException +{ + /// + /// Initialize a new instance of the class + /// + /// Exception message + /// Optional inner exception + public ProviderFatalException(string? message = null, Exception? innerException = null) + : base(ErrorType.ProviderFatal, message, innerException) + { + } +} diff --git a/src/OpenFeature/Error/ProviderNotReadyException.cs b/src/OpenFeature/Error/ProviderNotReadyException.cs new file mode 100644 index 00000000..5d2e3af1 --- /dev/null +++ b/src/OpenFeature/Error/ProviderNotReadyException.cs @@ -0,0 +1,22 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using OpenFeature.Constant; + +namespace OpenFeature.Error; + +/// +/// Provider has not yet been initialized when evaluating a flag. +/// +[ExcludeFromCodeCoverage] +public class ProviderNotReadyException : FeatureProviderException +{ + /// + /// Initialize a new instance of the class + /// + /// Exception message + /// Optional inner exception + public ProviderNotReadyException(string? message = null, Exception? innerException = null) + : base(ErrorType.ProviderNotReady, message, innerException) + { + } +} diff --git a/src/OpenFeature/Error/TargetingKeyMissingException.cs b/src/OpenFeature/Error/TargetingKeyMissingException.cs new file mode 100644 index 00000000..488009f4 --- /dev/null +++ b/src/OpenFeature/Error/TargetingKeyMissingException.cs @@ -0,0 +1,22 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using OpenFeature.Constant; + +namespace OpenFeature.Error; + +/// +/// Context does not contain a targeting key and the provider requires one when evaluating a flag. +/// +[ExcludeFromCodeCoverage] +public class TargetingKeyMissingException : FeatureProviderException +{ + /// + /// Initialize a new instance of the class + /// + /// Exception message + /// Optional inner exception + public TargetingKeyMissingException(string? message = null, Exception? innerException = null) + : base(ErrorType.TargetingKeyMissing, message, innerException) + { + } +} diff --git a/src/OpenFeature/Error/TypeMismatchException.cs b/src/OpenFeature/Error/TypeMismatchException.cs new file mode 100644 index 00000000..2df3b29f --- /dev/null +++ b/src/OpenFeature/Error/TypeMismatchException.cs @@ -0,0 +1,22 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using OpenFeature.Constant; + +namespace OpenFeature.Error; + +/// +/// Request type does not match the expected type when evaluating a flag. +/// +[ExcludeFromCodeCoverage] +public class TypeMismatchException : FeatureProviderException +{ + /// + /// Initialize a new instance of the class + /// + /// Exception message + /// Optional inner exception + public TypeMismatchException(string? message = null, Exception? innerException = null) + : base(ErrorType.TypeMismatch, message, innerException) + { + } +} diff --git a/src/OpenFeature/EventExecutor.cs b/src/OpenFeature/EventExecutor.cs new file mode 100644 index 00000000..edb75780 --- /dev/null +++ b/src/OpenFeature/EventExecutor.cs @@ -0,0 +1,355 @@ +using System; +using System.Collections.Generic; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using OpenFeature.Constant; +using OpenFeature.Model; + +namespace OpenFeature; + +internal sealed partial class EventExecutor : IAsyncDisposable +{ + private readonly object _lockObj = new(); + public readonly Channel EventChannel = Channel.CreateBounded(1); + private FeatureProvider? _defaultProvider; + private readonly Dictionary _namedProviderReferences = []; + private readonly List _activeSubscriptions = []; + + private readonly Dictionary> _apiHandlers = []; + private readonly Dictionary>> _clientHandlers = []; + + private ILogger _logger; + + public EventExecutor() + { + this._logger = NullLogger.Instance; + Task.Run(this.ProcessEventAsync); + } + + public ValueTask DisposeAsync() => new(this.ShutdownAsync()); + + internal void SetLogger(ILogger logger) => this._logger = logger; + + internal void AddApiLevelHandler(ProviderEventTypes eventType, EventHandlerDelegate handler) + { + lock (this._lockObj) + { + if (!this._apiHandlers.TryGetValue(eventType, out var eventHandlers)) + { + eventHandlers = []; + this._apiHandlers[eventType] = eventHandlers; + } + + eventHandlers.Add(handler); + + this.EmitOnRegistration(this._defaultProvider, eventType, handler); + } + } + + internal void RemoveApiLevelHandler(ProviderEventTypes type, EventHandlerDelegate handler) + { + lock (this._lockObj) + { + if (this._apiHandlers.TryGetValue(type, out var eventHandlers)) + { + eventHandlers.Remove(handler); + } + } + } + + internal void AddClientHandler(string client, ProviderEventTypes eventType, EventHandlerDelegate handler) + { + lock (this._lockObj) + { + // check if there is already a list of handlers for the given client and event type + if (!this._clientHandlers.TryGetValue(client, out var registry)) + { + registry = []; + this._clientHandlers[client] = registry; + } + + if (!this._clientHandlers[client].TryGetValue(eventType, out var eventHandlers)) + { + eventHandlers = []; + this._clientHandlers[client][eventType] = eventHandlers; + } + + this._clientHandlers[client][eventType].Add(handler); + + this.EmitOnRegistration( + this._namedProviderReferences.TryGetValue(client, out var clientProviderReference) + ? clientProviderReference + : this._defaultProvider, eventType, handler); + } + } + + internal void RemoveClientHandler(string client, ProviderEventTypes type, EventHandlerDelegate handler) + { + lock (this._lockObj) + { + if (this._clientHandlers.TryGetValue(client, out var clientEventHandlers)) + { + if (clientEventHandlers.TryGetValue(type, out var eventHandlers)) + { + eventHandlers.Remove(handler); + } + } + } + } + + internal void RegisterDefaultFeatureProvider(FeatureProvider? provider) + { + if (provider == null) + { + return; + } + lock (this._lockObj) + { + var oldProvider = this._defaultProvider; + + this._defaultProvider = provider; + + this.StartListeningAndShutdownOld(this._defaultProvider, oldProvider); + } + } + + internal void RegisterClientFeatureProvider(string client, FeatureProvider? provider) + { + if (provider == null) + { + return; + } + lock (this._lockObj) + { + FeatureProvider? oldProvider = null; + if (this._namedProviderReferences.TryGetValue(client, out var foundOldProvider)) + { + oldProvider = foundOldProvider; + } + + this._namedProviderReferences[client] = provider; + + this.StartListeningAndShutdownOld(provider, oldProvider); + } + } + + private void StartListeningAndShutdownOld(FeatureProvider newProvider, FeatureProvider? oldProvider) + { + // check if the provider is already active - if not, we need to start listening for its emitted events + if (!this.IsProviderActive(newProvider)) + { + this._activeSubscriptions.Add(newProvider); + Task.Run(() => this.ProcessFeatureProviderEventsAsync(newProvider)); + } + + if (oldProvider != null && !this.IsProviderBound(oldProvider)) + { + this._activeSubscriptions.Remove(oldProvider); + oldProvider.GetEventChannel().Writer.Complete(); + } + } + + private bool IsProviderBound(FeatureProvider provider) + { + if (this._defaultProvider == provider) + { + return true; + } + foreach (var providerReference in this._namedProviderReferences.Values) + { + if (providerReference == provider) + { + return true; + } + } + return false; + } + + private bool IsProviderActive(FeatureProvider providerRef) + { + return this._activeSubscriptions.Contains(providerRef); + } + + private void EmitOnRegistration(FeatureProvider? provider, ProviderEventTypes eventType, EventHandlerDelegate handler) + { + if (provider == null) + { + return; + } + var status = provider.Status; + + var message = status switch + { + ProviderStatus.Ready when eventType == ProviderEventTypes.ProviderReady => "Provider is ready", + ProviderStatus.Error when eventType == ProviderEventTypes.ProviderError => "Provider is in error state", + ProviderStatus.Stale when eventType == ProviderEventTypes.ProviderStale => "Provider is in stale state", + _ => string.Empty + }; + + if (string.IsNullOrWhiteSpace(message)) + { + return; + } + + try + { + handler.Invoke(new ProviderEventPayload + { + ProviderName = provider.GetMetadata()?.Name, + Type = eventType, + Message = message + }); + } + catch (Exception exc) + { + this.ErrorRunningHandler(exc); + } + } + + private async Task ProcessFeatureProviderEventsAsync(FeatureProvider provider) + { + if (provider.GetEventChannel() is not { Reader: { } reader }) + { + return; + } + + while (await reader.WaitToReadAsync().ConfigureAwait(false)) + { + if (!reader.TryRead(out var item)) + continue; + + switch (item) + { + case ProviderEventPayload eventPayload: + UpdateProviderStatus(provider, eventPayload); + await this.EventChannel.Writer.WriteAsync(new Event { Provider = provider, EventPayload = eventPayload }).ConfigureAwait(false); + break; + } + } + } + + // Method to process events + private async Task ProcessEventAsync() + { + while (await this.EventChannel.Reader.WaitToReadAsync().ConfigureAwait(false)) + { + if (!this.EventChannel.Reader.TryRead(out var item)) + { + continue; + } + + if (item is not Event e) + { + continue; + } + + lock (this._lockObj) + { + this.ProcessApiHandlers(e); + this.ProcessClientHandlers(e); + this.ProcessDefaultProviderHandlers(e); + } + } + } + + private void ProcessApiHandlers(Event e) + { + if (e.EventPayload?.Type != null && this._apiHandlers.TryGetValue(e.EventPayload.Type, out var eventHandlers)) + { + foreach (var eventHandler in eventHandlers) + { + this.InvokeEventHandler(eventHandler, e); + } + } + } + + private void ProcessClientHandlers(Event e) + { + foreach (var keyAndValue in this._namedProviderReferences) + { + if (keyAndValue.Value == e.Provider + && this._clientHandlers.TryGetValue(keyAndValue.Key, out var clientRegistry) + && e.EventPayload?.Type != null + && clientRegistry.TryGetValue(e.EventPayload.Type, out var clientEventHandlers)) + { + foreach (var eventHandler in clientEventHandlers) + { + this.InvokeEventHandler(eventHandler, e); + } + } + } + } + + private void ProcessDefaultProviderHandlers(Event e) + { + if (e.Provider != this._defaultProvider) + { + return; + } + + foreach (var keyAndValues in this._clientHandlers) + { + if (this._namedProviderReferences.ContainsKey(keyAndValues.Key)) + { + continue; + } + + if (e.EventPayload?.Type != null && keyAndValues.Value.TryGetValue(e.EventPayload.Type, out var clientEventHandlers)) + { + foreach (var eventHandler in clientEventHandlers) + { + this.InvokeEventHandler(eventHandler, e); + } + } + } + } + + + // map events to provider status as per spec: https://openfeature.dev/specification/sections/events/#requirement-535 + private static void UpdateProviderStatus(FeatureProvider provider, ProviderEventPayload eventPayload) + { + switch (eventPayload.Type) + { + case ProviderEventTypes.ProviderReady: + provider.Status = ProviderStatus.Ready; + break; + case ProviderEventTypes.ProviderStale: + provider.Status = ProviderStatus.Stale; + break; + case ProviderEventTypes.ProviderError: + provider.Status = eventPayload.ErrorType == ErrorType.ProviderFatal ? ProviderStatus.Fatal : ProviderStatus.Error; + break; + case ProviderEventTypes.ProviderConfigurationChanged: + default: break; + } + } + + private void InvokeEventHandler(EventHandlerDelegate eventHandler, Event e) + { + try + { + eventHandler.Invoke(e.EventPayload); + } + catch (Exception exc) + { + this.ErrorRunningHandler(exc); + } + } + + public async Task ShutdownAsync() + { + this.EventChannel.Writer.Complete(); + await this.EventChannel.Reader.Completion.ConfigureAwait(false); + } + + [LoggerMessage(100, LogLevel.Error, "Error running handler")] + partial void ErrorRunningHandler(Exception exception); +} + +internal class Event +{ + internal FeatureProvider? Provider { get; set; } + internal ProviderEventPayload? EventPayload { get; set; } +} diff --git a/src/OpenFeature/Extension/EnumExtensions.cs b/src/OpenFeature/Extension/EnumExtensions.cs index 2647e383..d5d7e72b 100644 --- a/src/OpenFeature/Extension/EnumExtensions.cs +++ b/src/OpenFeature/Extension/EnumExtensions.cs @@ -2,15 +2,14 @@ using System.ComponentModel; using System.Linq; -namespace OpenFeature.SDK.Extension +namespace OpenFeature.Extension; + +internal static class EnumExtensions { - internal static class EnumExtensions + public static string GetDescription(this Enum value) { - public static string GetDescription(this Enum value) - { - var field = value.GetType().GetField(value.ToString()); - var attribute = field.GetCustomAttributes(typeof(DescriptionAttribute), false).FirstOrDefault() as DescriptionAttribute; - return attribute?.Description ?? value.ToString(); - } + var field = value.GetType().GetField(value.ToString()); + var attribute = field?.GetCustomAttributes(typeof(DescriptionAttribute), false).FirstOrDefault() as DescriptionAttribute; + return attribute?.Description ?? value.ToString(); } } diff --git a/src/OpenFeature/Extension/ResolutionDetailsExtensions.cs b/src/OpenFeature/Extension/ResolutionDetailsExtensions.cs index 823c6c3b..cf0d4f4a 100644 --- a/src/OpenFeature/Extension/ResolutionDetailsExtensions.cs +++ b/src/OpenFeature/Extension/ResolutionDetailsExtensions.cs @@ -1,12 +1,12 @@ -using OpenFeature.SDK.Model; +using OpenFeature.Model; -namespace OpenFeature.SDK.Extension +namespace OpenFeature.Extension; + +internal static class ResolutionDetailsExtensions { - internal static class ResolutionDetailsExtensions + public static FlagEvaluationDetails ToFlagEvaluationDetails(this ResolutionDetails details) { - public static FlagEvaluationDetails ToFlagEvaluationDetails(this ResolutionDetails details) - { - return new FlagEvaluationDetails(details.FlagKey, details.Value, details.ErrorType, details.Reason, details.Variant); - } + return new FlagEvaluationDetails(details.FlagKey, details.Value, details.ErrorType, details.Reason, + details.Variant, details.ErrorMessage, details.FlagMetadata); } } diff --git a/src/OpenFeature/FeatureProvider.cs b/src/OpenFeature/FeatureProvider.cs new file mode 100644 index 00000000..9c9d9327 --- /dev/null +++ b/src/OpenFeature/FeatureProvider.cs @@ -0,0 +1,152 @@ +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using OpenFeature.Constant; +using OpenFeature.Model; + +namespace OpenFeature; + +/// +/// The provider interface describes the abstraction layer for a feature flag provider. +/// A provider acts as it translates layer between the generic feature flag structure to a target feature flag system. +/// +/// Provider specification +public abstract class FeatureProvider +{ + /// + /// Gets an immutable list of hooks that belong to the provider. + /// By default, return an empty list + /// + /// Executed in the order of hooks + /// before: API, Client, Invocation, Provider + /// after: Provider, Invocation, Client, API + /// error (if applicable): Provider, Invocation, Client, API + /// finally: Provider, Invocation, Client, API + /// + /// Immutable list of hooks + public virtual IImmutableList GetProviderHooks() => ImmutableList.Empty; + + /// + /// The event channel of the provider. + /// + protected readonly Channel EventChannel = Channel.CreateBounded(1); + + /// + /// Metadata describing the provider. + /// + /// + public abstract Metadata? GetMetadata(); + + /// + /// Resolves a boolean feature flag + /// + /// Feature flag key + /// Default value + /// + /// The . + /// + public abstract Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default); + + /// + /// Resolves a string feature flag + /// + /// Feature flag key + /// Default value + /// + /// The . + /// + public abstract Task> ResolveStringValueAsync(string flagKey, string defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default); + + /// + /// Resolves a integer feature flag + /// + /// Feature flag key + /// Default value + /// + /// The . + /// + public abstract Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default); + + /// + /// Resolves a double feature flag + /// + /// Feature flag key + /// Default value + /// + /// The . + /// + public abstract Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default); + + /// + /// Resolves a structured feature flag + /// + /// Feature flag key + /// Default value + /// + /// The . + /// + public abstract Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default); + + /// + /// Internally-managed provider status. + /// The SDK uses this field to track the status of the provider. + /// Not visible outside OpenFeature assembly + /// + internal virtual ProviderStatus Status { get; set; } = ProviderStatus.NotReady; + + /// + /// + /// This method is called before a provider is used to evaluate flags. Providers can overwrite this method, + /// if they have special initialization needed prior being called for flag evaluation. + /// When this method completes, the provider will be considered ready for use. + /// + /// + /// + /// The to cancel any async side effects. + /// A task that completes when the initialization process is complete. + /// + /// + /// Providers not implementing this method will be considered ready immediately. + /// + /// + public virtual Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) + { + // Intentionally left blank. + return Task.CompletedTask; + } + + /// + /// This method is called when a new provider is about to be used to evaluate flags, or the SDK is shut down. + /// Providers can overwrite this method, if they have special shutdown actions needed. + /// + /// A task that completes when the shutdown process is complete. + /// The to cancel any async side effects. + public virtual Task ShutdownAsync(CancellationToken cancellationToken = default) + { + // Intentionally left blank. + return Task.CompletedTask; + } + + /// + /// Returns the event channel of the provider. + /// + /// The event channel of the provider + public Channel GetEventChannel() => this.EventChannel; + + /// + /// Track a user action or application state, usually representing a business objective or outcome. The implementation of this method is optional. + /// + /// The name associated with this tracking event + /// The evaluation context used in the evaluation of the flag (optional) + /// Data pertinent to the tracking event (Optional) + public virtual void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default) + { + // Intentionally left blank. + } +} diff --git a/src/OpenFeature/Hook.cs b/src/OpenFeature/Hook.cs index 866351e7..d38550ff 100644 --- a/src/OpenFeature/Hook.cs +++ b/src/OpenFeature/Hook.cs @@ -1,82 +1,87 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; -using OpenFeature.SDK.Model; +using OpenFeature.Model; -namespace OpenFeature.SDK +namespace OpenFeature; + +/// +/// The Hook abstract class describes the default implementation for a hook. +/// A hook has multiple lifecycles, and is called in the following order when normal execution Before, After, Finally. +/// When an abnormal execution occurs, the hook is called in the following order: Error, Finally. +/// +/// Before: immediately before flag evaluation +/// After: immediately after successful flag evaluation +/// Error: immediately after an unsuccessful during flag evaluation +/// Finally: unconditionally after flag evaluation +/// +/// Hooks can be configured to run globally (impacting all flag evaluations), per client, or per flag evaluation invocation. +/// +/// +/// Hook Specification +public abstract class Hook { - internal interface IHook + /// + /// Called immediately before flag evaluation. + /// + /// Provides context of innovation + /// Caller provided data + /// The . + /// Flag value type (bool|number|string|object) + /// Modified EvaluationContext that is used for the flag evaluation + public virtual ValueTask BeforeAsync(HookContext context, + IReadOnlyDictionary? hints = null, + CancellationToken cancellationToken = default) { - Task Before(HookContext context, IReadOnlyDictionary hints = null); - Task After(HookContext context, FlagEvaluationDetails details, IReadOnlyDictionary hints = null); - Task Error(HookContext context, Exception error, IReadOnlyDictionary hints = null); - Task Finally(HookContext context, IReadOnlyDictionary hints = null); + return new ValueTask(EvaluationContext.Empty); } /// - /// The Hook abstract class describes the default implementation for a hook. - /// A hook has multiple lifecycles, and is called in the following order when normal execution Before, After, Finally. - /// When an abnormal execution occurs, the hook is called in the following order: Error, Finally. - /// - /// Before: immediately before flag evaluation - /// After: immediately after successful flag evaluation - /// Error: immediately after an unsuccessful during flag evaluation - /// Finally: unconditionally after flag evaluation - /// - /// Hooks can be configured to run globally (impacting all flag evaluations), per client, or per flag evaluation invocation. - /// + /// Called immediately after successful flag evaluation. /// - /// Hook Specification - public abstract class Hook : IHook + /// Provides context of innovation + /// Flag evaluation information + /// Caller provided data + /// The . + /// Flag value type (bool|number|string|object) + public virtual ValueTask AfterAsync(HookContext context, + FlagEvaluationDetails details, + IReadOnlyDictionary? hints = null, + CancellationToken cancellationToken = default) { - /// - /// Called immediately before flag evaluation. - /// - /// Provides context of innovation - /// Caller provided data - /// Flag value type (bool|number|string|object) - /// Modified EvaluationContext that is used for the flag evaluation - public virtual Task Before(HookContext context, - IReadOnlyDictionary hints = null) - { - return Task.FromResult(new EvaluationContext()); - } - - /// - /// Called immediately after successful flag evaluation. - /// - /// Provides context of innovation - /// Flag evaluation information - /// Caller provided data - /// Flag value type (bool|number|string|object) - public virtual Task After(HookContext context, FlagEvaluationDetails details, - IReadOnlyDictionary hints = null) - { - return Task.CompletedTask; - } + return new ValueTask(); + } - /// - /// Called immediately after an unsuccessful flag evaluation. - /// - /// Provides context of innovation - /// Exception representing what went wrong - /// Caller provided data - /// Flag value type (bool|number|string|object) - public virtual Task Error(HookContext context, Exception error, - IReadOnlyDictionary hints = null) - { - return Task.CompletedTask; - } + /// + /// Called immediately after an unsuccessful flag evaluation. + /// + /// Provides context of innovation + /// Exception representing what went wrong + /// Caller provided data + /// The . + /// Flag value type (bool|number|string|object) + public virtual ValueTask ErrorAsync(HookContext context, + Exception error, + IReadOnlyDictionary? hints = null, + CancellationToken cancellationToken = default) + { + return new ValueTask(); + } - /// - /// Called unconditionally after flag evaluation. - /// - /// Provides context of innovation - /// Caller provided data - /// Flag value type (bool|number|string|object) - public virtual Task Finally(HookContext context, IReadOnlyDictionary hints = null) - { - return Task.CompletedTask; - } + /// + /// Called unconditionally after flag evaluation. + /// + /// Provides context of innovation + /// Flag evaluation information + /// Caller provided data + /// The . + /// Flag value type (bool|number|string|object) + public virtual ValueTask FinallyAsync(HookContext context, + FlagEvaluationDetails evaluationDetails, + IReadOnlyDictionary? hints = null, + CancellationToken cancellationToken = default) + { + return new ValueTask(); } } diff --git a/src/OpenFeature/HookData.cs b/src/OpenFeature/HookData.cs new file mode 100644 index 00000000..ecfdfabd --- /dev/null +++ b/src/OpenFeature/HookData.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using OpenFeature.Model; + +namespace OpenFeature; + +/// +/// A key-value collection of strings to objects used for passing data between hook stages. +/// +/// This collection is scoped to a single evaluation for a single hook. Each hook stage for the evaluation +/// will share the same . +/// +/// +/// This collection is intended for use only during the execution of individual hook stages, a reference +/// to the collection should not be retained. +/// +/// +/// This collection is not thread-safe. +/// +/// +/// +public sealed class HookData +{ + private readonly Dictionary _data = []; + + /// + /// Set the key to the given value. + /// + /// The key for the value + /// The value to set + /// This hook data instance + public HookData Set(string key, object value) + { + this._data[key] = value; + return this; + } + + /// + /// Gets the value at the specified key as an object. + /// + /// For types use instead. + /// + /// + /// The key of the value to be retrieved + /// The object associated with the key + /// + /// Thrown when the context does not contain the specified key + /// + public object Get(string key) + { + return this._data[key]; + } + + /// + /// Return a count of all values. + /// + public int Count => this._data.Count; + + /// + /// Return an enumerator for all values. + /// + /// An enumerator for all values + public IEnumerator> GetEnumerator() + { + return this._data.GetEnumerator(); + } + + /// + /// Return a list containing all the keys in the hook data + /// + public IImmutableList Keys => this._data.Keys.ToImmutableList(); + + /// + /// Return an enumerable containing all the values of the hook data + /// + public IImmutableList Values => this._data.Values.ToImmutableList(); + + /// + /// Gets all values as a read only dictionary. + /// + /// The dictionary references the original values and is not a thread-safe copy. + /// + /// + /// A representation of the hook data + public IReadOnlyDictionary AsDictionary() + { + return this._data; + } + + /// + /// Gets or sets the value associated with the specified key. + /// + /// The key of the value to get or set + /// The value associated with the specified key + /// + /// Thrown when getting a value and the context does not contain the specified key + /// + public object this[string key] + { + get => this.Get(key); + set => this.Set(key, value); + } +} diff --git a/src/OpenFeature/HookRunner.cs b/src/OpenFeature/HookRunner.cs new file mode 100644 index 00000000..c80b8613 --- /dev/null +++ b/src/OpenFeature/HookRunner.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using OpenFeature.Model; + +namespace OpenFeature; + +/// +/// This class manages the execution of hooks. +/// +/// type of the evaluation detail provided to the hooks +internal partial class HookRunner +{ + private readonly ImmutableList _hooks; + + private readonly List> _hookContexts; + + private EvaluationContext _evaluationContext; + + private readonly ILogger _logger; + + /// + /// Construct a hook runner instance. Each instance should be used for the execution of a single evaluation. + /// + /// + /// The hooks for the evaluation, these should be in the correct order for the before evaluation stage + /// + /// + /// The initial evaluation context, this can be updated as the hooks execute + /// + /// + /// Contents of the initial hook context excluding the evaluation context and hook data + /// + /// Client logger instance + public HookRunner(ImmutableList hooks, EvaluationContext evaluationContext, + SharedHookContext sharedHookContext, + ILogger logger) + { + this._evaluationContext = evaluationContext; + this._logger = logger; + this._hooks = hooks; + this._hookContexts = new List>(hooks.Count); + for (var i = 0; i < hooks.Count; i++) + { + // Create hook instance specific hook context. + // Hook contexts are instance specific so that the mutable hook data is scoped to each hook. + this._hookContexts.Add(sharedHookContext.ToHookContext(evaluationContext)); + } + } + + /// + /// Execute before hooks. + /// + /// Optional hook hints + /// Cancellation token which can cancel hook operations + /// Context with any modifications from the before hooks + public async Task TriggerBeforeHooksAsync(IImmutableDictionary? hints, + CancellationToken cancellationToken = default) + { + var evalContextBuilder = EvaluationContext.Builder(); + evalContextBuilder.Merge(this._evaluationContext); + + for (var i = 0; i < this._hooks.Count; i++) + { + var hook = this._hooks[i]; + var hookContext = this._hookContexts[i]; + + var resp = await hook.BeforeAsync(hookContext, hints, cancellationToken) + .ConfigureAwait(false); + if (resp != null) + { + evalContextBuilder.Merge(resp); + this._evaluationContext = evalContextBuilder.Build(); + for (var j = 0; j < this._hookContexts.Count; j++) + { + this._hookContexts[j] = this._hookContexts[j].WithNewEvaluationContext(this._evaluationContext); + } + } + else + { + this.HookReturnedNull(hook.GetType().Name); + } + } + + return this._evaluationContext; + } + + /// + /// Execute the after hooks. These are executed in opposite order of the before hooks. + /// + /// The evaluation details which will be provided to the hook + /// Optional hook hints + /// Cancellation token which can cancel hook operations + public async Task TriggerAfterHooksAsync(FlagEvaluationDetails evaluationDetails, + IImmutableDictionary? hints, + CancellationToken cancellationToken = default) + { + // After hooks run in reverse. + for (var i = this._hooks.Count - 1; i >= 0; i--) + { + var hook = this._hooks[i]; + var hookContext = this._hookContexts[i]; + await hook.AfterAsync(hookContext, evaluationDetails, hints, cancellationToken) + .ConfigureAwait(false); + } + } + + /// + /// Execute the error hooks. These are executed in opposite order of the before hooks. + /// + /// Exception which triggered the error + /// Optional hook hints + /// Cancellation token which can cancel hook operations + public async Task TriggerErrorHooksAsync(Exception exception, + IImmutableDictionary? hints, CancellationToken cancellationToken = default) + { + // Error hooks run in reverse. + for (var i = this._hooks.Count - 1; i >= 0; i--) + { + var hook = this._hooks[i]; + var hookContext = this._hookContexts[i]; + try + { + await hook.ErrorAsync(hookContext, exception, hints, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception e) + { + this.ErrorHookError(hook.GetType().Name, e); + } + } + } + + /// + /// Execute the finally hooks. These are executed in opposite order of the before hooks. + /// + /// The evaluation details which will be provided to the hook + /// Optional hook hints + /// Cancellation token which can cancel hook operations + public async Task TriggerFinallyHooksAsync(FlagEvaluationDetails evaluationDetails, + IImmutableDictionary? hints, + CancellationToken cancellationToken = default) + { + // Finally hooks run in reverse + for (var i = this._hooks.Count - 1; i >= 0; i--) + { + var hook = this._hooks[i]; + var hookContext = this._hookContexts[i]; + try + { + await hook.FinallyAsync(hookContext, evaluationDetails, hints, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception e) + { + this.FinallyHookError(hook.GetType().Name, e); + } + } + } + + [LoggerMessage(100, LogLevel.Debug, "Hook {HookName} returned null, nothing to merge back into context")] + partial void HookReturnedNull(string hookName); + + [LoggerMessage(103, LogLevel.Error, "Error while executing Error hook {HookName}")] + partial void ErrorHookError(string hookName, Exception exception); + + [LoggerMessage(104, LogLevel.Error, "Error while executing Finally hook {HookName}")] + partial void FinallyHookError(string hookName, Exception exception); +} diff --git a/src/OpenFeature/Hooks/LoggingHook.cs b/src/OpenFeature/Hooks/LoggingHook.cs new file mode 100644 index 00000000..b8308167 --- /dev/null +++ b/src/OpenFeature/Hooks/LoggingHook.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using OpenFeature.Model; + +namespace OpenFeature.Hooks; + +/// +/// The logging hook is a hook which logs messages during the flag evaluation life-cycle. +/// +public sealed partial class LoggingHook : Hook +{ + private readonly ILogger _logger; + private readonly bool _includeContext; + + /// + /// Initialise a with a and optional Evaluation Context. will + /// include properties in the to the generated logs. + /// + public LoggingHook(ILogger logger, bool includeContext = false) + { + this._logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this._includeContext = includeContext; + } + + /// + public override ValueTask BeforeAsync(HookContext context, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + var evaluationContext = this._includeContext ? context.EvaluationContext : null; + + var content = new LoggingHookContent( + context.ClientMetadata.Name, + context.ProviderMetadata.Name, + context.FlagKey, + context.DefaultValue?.ToString(), + evaluationContext); + + this.HookBeforeStageExecuted(content); + + return base.BeforeAsync(context, hints, cancellationToken); + } + + /// + public override ValueTask ErrorAsync(HookContext context, Exception error, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + var evaluationContext = this._includeContext ? context.EvaluationContext : null; + + var content = new LoggingHookContent( + context.ClientMetadata.Name, + context.ProviderMetadata.Name, + context.FlagKey, + context.DefaultValue?.ToString(), + evaluationContext); + + this.HookErrorStageExecuted(content); + + return base.ErrorAsync(context, error, hints, cancellationToken); + } + + /// + public override ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + var evaluationContext = this._includeContext ? context.EvaluationContext : null; + + var content = new LoggingHookContent( + context.ClientMetadata.Name, + context.ProviderMetadata.Name, + context.FlagKey, + context.DefaultValue?.ToString(), + evaluationContext); + + this.HookAfterStageExecuted(content); + + return base.AfterAsync(context, details, hints, cancellationToken); + } + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Before Flag Evaluation {Content}")] + partial void HookBeforeStageExecuted(LoggingHookContent content); + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Error during Flag Evaluation {Content}")] + partial void HookErrorStageExecuted(LoggingHookContent content); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "After Flag Evaluation {Content}")] + partial void HookAfterStageExecuted(LoggingHookContent content); + + /// + /// Generates a log string with contents provided by the . + /// + /// Specification for log contents found at https://github.com/open-feature/spec/blob/d261f68331b94fd8ed10bc72bc0485cfc72a51a8/specification/appendix-a-included-utilities.md#logging-hook + /// + /// + internal class LoggingHookContent + { + private readonly string _domain; + private readonly string _providerName; + private readonly string _flagKey; + private readonly string _defaultValue; + private readonly EvaluationContext? _evaluationContext; + + public LoggingHookContent(string? domain, string? providerName, string flagKey, string? defaultValue, EvaluationContext? evaluationContext = null) + { + this._domain = string.IsNullOrEmpty(domain) ? "missing" : domain!; + this._providerName = string.IsNullOrEmpty(providerName) ? "missing" : providerName!; + this._flagKey = flagKey; + this._defaultValue = string.IsNullOrEmpty(defaultValue) ? "missing" : defaultValue!; + this._evaluationContext = evaluationContext; + } + + public override string ToString() + { + var stringBuilder = new StringBuilder(); + + stringBuilder.Append("Domain:"); + stringBuilder.AppendLine(this._domain); + + stringBuilder.Append("ProviderName:"); + stringBuilder.AppendLine(this._providerName); + + stringBuilder.Append("FlagKey:"); + stringBuilder.AppendLine(this._flagKey); + + stringBuilder.Append("DefaultValue:"); + stringBuilder.AppendLine(this._defaultValue); + + if (this._evaluationContext != null) + { + stringBuilder.AppendLine("Context:"); + foreach (var kvp in this._evaluationContext.AsDictionary()) + { + stringBuilder.Append('\t'); + stringBuilder.Append(kvp.Key); + stringBuilder.Append(':'); + stringBuilder.AppendLine(GetValueString(kvp.Value)); + } + } + + return stringBuilder.ToString(); + } + + static string? GetValueString(Value value) + { + if (value.IsNull) + return string.Empty; + + if (value.IsString) + return value.AsString; + + if (value.IsBoolean) + return value.AsBoolean.ToString(); + + if (value.IsNumber) + { + // Value.AsDouble will attempt to cast other numbers to double + // There is an implicit conversation for int/long to double + if (value.AsDouble != null) return value.AsDouble.ToString(); + } + + if (value.IsDateTime) + return value.AsDateTime?.ToString("O"); + + return value.ToString(); + } + } +} diff --git a/src/OpenFeature/IEventBus.cs b/src/OpenFeature/IEventBus.cs new file mode 100644 index 00000000..bb1cd91e --- /dev/null +++ b/src/OpenFeature/IEventBus.cs @@ -0,0 +1,23 @@ +using OpenFeature.Constant; +using OpenFeature.Model; + +namespace OpenFeature; + +/// +/// Defines the methods required for handling events. +/// +public interface IEventBus +{ + /// + /// Adds an Event Handler for the given event type. + /// + /// The type of the event + /// Implementation of the + void AddHandler(ProviderEventTypes type, EventHandlerDelegate handler); + /// + /// Removes an Event Handler for the given event type. + /// + /// The type of the event + /// Implementation of the + void RemoveHandler(ProviderEventTypes type, EventHandlerDelegate handler); +} diff --git a/src/OpenFeature/IFeatureClient.cs b/src/OpenFeature/IFeatureClient.cs index cb0f7c62..c14e6e4b 100644 --- a/src/OpenFeature/IFeatureClient.cs +++ b/src/OpenFeature/IFeatureClient.cs @@ -1,24 +1,172 @@ using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; -using OpenFeature.SDK.Model; +using OpenFeature.Constant; +using OpenFeature.Model; -namespace OpenFeature.SDK +namespace OpenFeature; + +/// +/// Interface used to resolve flags of varying types. +/// +public interface IFeatureClient : IEventBus { - internal interface IFeatureClient - { - void AddHooks(IEnumerable hooks); - ClientMetadata GetMetadata(); + /// + /// Appends hooks to client + /// + /// The appending operation will be atomic. + /// + /// + /// A list of Hooks that implement the interface + void AddHooks(IEnumerable hooks); + + /// + /// Enumerates the global hooks. + /// + /// The items enumerated will reflect the registered hooks + /// at the start of enumeration. Hooks added during enumeration + /// will not be included. + /// + /// + /// Enumeration of + IEnumerable GetHooks(); + + /// + /// Gets the of this client + /// + /// The evaluation context may be set from multiple threads, when accessing the client evaluation context + /// it should be accessed once for an operation, and then that reference should be used for all dependent + /// operations. + /// + /// + /// of this client + EvaluationContext GetContext(); + + /// + /// Sets the of the client + /// + /// The to set + void SetContext(EvaluationContext context); + + /// + /// Gets client metadata + /// + /// Client metadata + ClientMetadata GetMetadata(); + + /// + /// Returns the current status of the associated provider. + /// + /// + ProviderStatus ProviderStatus { get; } + + /// + /// Resolves a boolean feature flag + /// + /// Feature flag key + /// Default value + /// Evaluation Context + /// Flag Evaluation Options + /// The . + /// Resolved flag value. + Task GetBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); + + /// + /// Resolves a boolean feature flag + /// + /// Feature flag key + /// Default value + /// Evaluation Context + /// Flag Evaluation Options + /// The . + /// Resolved flag details + Task> GetBooleanDetailsAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); + + /// + /// Resolves a string feature flag + /// + /// Feature flag key + /// Default value + /// Evaluation Context + /// Flag Evaluation Options + /// The . + /// Resolved flag value. + Task GetStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); + + /// + /// Resolves a string feature flag + /// + /// Feature flag key + /// Default value + /// Evaluation Context + /// Flag Evaluation Options + /// The . + /// Resolved flag details + Task> GetStringDetailsAsync(string flagKey, string defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); + + /// + /// Resolves a integer feature flag + /// + /// Feature flag key + /// Default value + /// Evaluation Context + /// Flag Evaluation Options + /// The . + /// Resolved flag value. + Task GetIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); + + /// + /// Resolves a integer feature flag + /// + /// Feature flag key + /// Default value + /// Evaluation Context + /// Flag Evaluation Options + /// The . + /// Resolved flag details + Task> GetIntegerDetailsAsync(string flagKey, int defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); - Task GetBooleanValue(string flagKey, bool defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null); - Task> GetBooleanDetails(string flagKey, bool defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null); + /// + /// Resolves a double feature flag + /// + /// Feature flag key + /// Default value + /// Evaluation Context + /// Flag Evaluation Options + /// The . + /// Resolved flag value. + Task GetDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); - Task GetStringValue(string flagKey, string defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null); - Task> GetStringDetails(string flagKey, string defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null); + /// + /// Resolves a double feature flag + /// + /// Feature flag key + /// Default value + /// Evaluation Context + /// Flag Evaluation Options + /// The . + /// Resolved flag details + Task> GetDoubleDetailsAsync(string flagKey, double defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); - Task GetNumberValue(string flagKey, int defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null); - Task> GetNumberDetails(string flagKey, int defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null); + /// + /// Resolves a structure object feature flag + /// + /// Feature flag key + /// Default value + /// Evaluation Context + /// Flag Evaluation Options + /// The . + /// Resolved flag value. + Task GetObjectValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); - Task GetObjectValue(string flagKey, T defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null); - Task> GetObjectDetails(string flagKey, T defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null); - } + /// + /// Resolves a structure object feature flag + /// + /// Feature flag key + /// Default value + /// Evaluation Context + /// Flag Evaluation Options + /// The . + /// Resolved flag details + Task> GetObjectDetailsAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); } diff --git a/src/OpenFeature/IFeatureProvider.cs b/src/OpenFeature/IFeatureProvider.cs deleted file mode 100644 index 303832c1..00000000 --- a/src/OpenFeature/IFeatureProvider.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.Threading.Tasks; -using OpenFeature.SDK.Model; - -namespace OpenFeature.SDK -{ - /// - /// The provider interface describes the abstraction layer for a feature flag provider. - /// A provider acts as the translates layer between the generic feature flag structure to a target feature flag system. - /// - /// Provider specification - public interface IFeatureProvider - { - /// - /// Metadata describing the provider. - /// - /// - Metadata GetMetadata(); - - /// - /// Resolves a boolean feature flag - /// - /// Feature flag key - /// Default value - /// - /// - /// - Task> ResolveBooleanValue(string flagKey, bool defaultValue, - EvaluationContext context = null, FlagEvaluationOptions config = null); - - /// - /// Resolves a string feature flag - /// - /// Feature flag key - /// Default value - /// - /// - /// - Task> ResolveStringValue(string flagKey, string defaultValue, - EvaluationContext context = null, FlagEvaluationOptions config = null); - - /// - /// Resolves a number feature flag - /// - /// Feature flag key - /// Default value - /// - /// - /// - Task> ResolveNumberValue(string flagKey, int defaultValue, - EvaluationContext context = null, FlagEvaluationOptions config = null); - - /// - /// Resolves a structured feature flag - /// - /// Feature flag key - /// Default value - /// - /// - /// Type of object - /// - Task> ResolveStructureValue(string flagKey, T defaultValue, - EvaluationContext context = null, FlagEvaluationOptions config = null); - } -} diff --git a/src/OpenFeature/Model/ClientMetadata.cs b/src/OpenFeature/Model/ClientMetadata.cs index 7a502a6a..ffdc4eeb 100644 --- a/src/OpenFeature/Model/ClientMetadata.cs +++ b/src/OpenFeature/Model/ClientMetadata.cs @@ -1,23 +1,22 @@ -namespace OpenFeature.SDK.Model +namespace OpenFeature.Model; + +/// +/// Represents the client metadata +/// +public sealed class ClientMetadata : Metadata { /// - /// Represents the client metadata + /// Version of the client /// - public class ClientMetadata : Metadata - { - /// - /// Version of the client - /// - public string Version { get; } + public string? Version { get; } - /// - /// Initializes a new instance of the class - /// - /// Name of client - /// Version of client - public ClientMetadata(string name, string version) : base(name) - { - this.Version = version; - } + /// + /// Initializes a new instance of the class + /// + /// Name of client + /// Version of client + public ClientMetadata(string? name, string? version) : base(name) + { + this.Version = version; } } diff --git a/src/OpenFeature/Model/EvaluationContext.cs b/src/OpenFeature/Model/EvaluationContext.cs index 038217dc..ed4f989a 100644 --- a/src/OpenFeature/Model/EvaluationContext.cs +++ b/src/OpenFeature/Model/EvaluationContext.cs @@ -1,105 +1,122 @@ using System; -using System.Collections; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; +using System.Collections.Immutable; -namespace OpenFeature.SDK.Model +namespace OpenFeature.Model; + +/// +/// A KeyValuePair with a string key and object value that is used to apply user defined properties +/// to the feature flag evaluation context. +/// +/// Evaluation context +public sealed class EvaluationContext { /// - /// A KeyValuePair with a string key and object value that is used to apply user defined properties - /// to the feature flag evaluation context. + /// The index for the "targeting key" property when the EvaluationContext is serialized or expressed as a dictionary. /// - /// Evaluation context - public class EvaluationContext : IEnumerable> + internal const string TargetingKeyIndex = "targetingKey"; + + + private readonly Structure _structure; + + /// + /// Internal constructor used by the builder. + /// + /// + internal EvaluationContext(Structure content) { - private readonly Dictionary _internalContext = new Dictionary(); - - /// - /// Add a new key value pair to the evaluation context - /// - /// Key - /// Value - /// Type of value - public void Add(string key, T value) - { - this._internalContext.Add(key, value); - } + this._structure = content; + } - /// - /// Remove an object by key from the evaluation context - /// - /// Key - /// Key is null - public bool Remove(string key) - { - return this._internalContext.Remove(key); - } - /// - /// Get an object from evaluation context by key - /// - /// Key - /// Type of object - /// Object casted to provided type - /// A type mismatch occurs - public T Get(string key) - { - return (T)this._internalContext[key]; - } + /// + /// Private constructor for making an empty . + /// + private EvaluationContext() + { + this._structure = Structure.Empty; + } - /// - /// Get value by key - /// - /// Note: this will not case the object to type. - /// This will need to be done by the caller - /// - /// Key - public object this[string key] - { - get => this._internalContext[key]; - set => this._internalContext[key] = value; - } + /// + /// An empty evaluation context. + /// + public static EvaluationContext Empty { get; } = new EvaluationContext(); - /// - /// Merges provided evaluation context into this one - /// - /// Any duplicate keys will be overwritten - /// - /// - public void Merge(EvaluationContext other) - { - foreach (var key in other._internalContext.Keys) - { - if (this._internalContext.ContainsKey(key)) - { - this._internalContext[key] = other._internalContext[key]; - } - else - { - this._internalContext.Add(key, other._internalContext[key]); - } - } - } + /// + /// Gets the Value at the specified key + /// + /// The key of the value to be retrieved + /// The associated with the key + /// + /// Thrown when the context does not contain the specified key + /// + /// + /// Thrown when the key is + /// + public Value GetValue(string key) => this._structure.GetValue(key); - /// - /// Returns the number of items in the evaluation context - /// - public int Count => this._internalContext.Count; - - /// - /// Returns an enumerator that iterates through the evaluation context - /// - /// Enumerator of the Evaluation context - [ExcludeFromCodeCoverage] - public IEnumerator> GetEnumerator() - { - return this._internalContext.GetEnumerator(); - } + /// + /// Bool indicating if the specified key exists in the evaluation context + /// + /// The key of the value to be checked + /// indicating the presence of the key + /// + /// Thrown when the key is + /// + public bool ContainsKey(string key) => this._structure.ContainsKey(key); + + /// + /// Gets the value associated with the specified key + /// + /// The or if the key was not present + /// The key of the value to be retrieved + /// indicating the presence of the key + /// + /// Thrown when the key is + /// + public bool TryGetValue(string key, out Value? value) => this._structure.TryGetValue(key, out value); + + /// + /// Gets all values as a Dictionary + /// + /// New representation of this Structure + public IImmutableDictionary AsDictionary() + { + return this._structure.AsDictionary(); + } + + /// + /// Return a count of all values + /// + public int Count => this._structure.Count; - [ExcludeFromCodeCoverage] - IEnumerator IEnumerable.GetEnumerator() + /// + /// Returns the targeting key for the context. + /// + public string? TargetingKey + { + get { - return this.GetEnumerator(); + this._structure.TryGetValue(TargetingKeyIndex, out Value? targetingKey); + return targetingKey?.AsString; } } + + /// + /// Return an enumerator for all values + /// + /// An enumerator for all values + public IEnumerator> GetEnumerator() + { + return this._structure.GetEnumerator(); + } + + /// + /// Get a builder which can build an . + /// + /// The builder + public static EvaluationContextBuilder Builder() + { + return new EvaluationContextBuilder(); + } } diff --git a/src/OpenFeature/Model/EvaluationContextBuilder.cs b/src/OpenFeature/Model/EvaluationContextBuilder.cs new file mode 100644 index 00000000..3d85ba98 --- /dev/null +++ b/src/OpenFeature/Model/EvaluationContextBuilder.cs @@ -0,0 +1,155 @@ +using System; + +namespace OpenFeature.Model; + +/// +/// A builder which allows the specification of attributes for an . +/// +/// A object is intended for use by a single thread and should not be used +/// from multiple threads. Once an has been created it is immutable and safe for use +/// from multiple threads. +/// +/// +public sealed class EvaluationContextBuilder +{ + private readonly StructureBuilder _attributes = Structure.Builder(); + + /// + /// Internal to only allow direct creation by . + /// + internal EvaluationContextBuilder() { } + + /// + /// Set the targeting key for the context. + /// + /// The targeting key + /// This builder + public EvaluationContextBuilder SetTargetingKey(string targetingKey) + { + this._attributes.Set(EvaluationContext.TargetingKeyIndex, targetingKey); + return this; + } + + /// + /// Set the key to the given . + /// + /// The key for the value + /// The value to set + /// This builder + public EvaluationContextBuilder Set(string key, Value value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Set the key to the given string. + /// + /// The key for the value + /// The value to set + /// This builder + public EvaluationContextBuilder Set(string key, string value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Set the key to the given int. + /// + /// The key for the value + /// The value to set + /// This builder + public EvaluationContextBuilder Set(string key, int value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Set the key to the given double. + /// + /// The key for the value + /// The value to set + /// This builder + public EvaluationContextBuilder Set(string key, double value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Set the key to the given long. + /// + /// The key for the value + /// The value to set + /// This builder + public EvaluationContextBuilder Set(string key, long value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Set the key to the given bool. + /// + /// The key for the value + /// The value to set + /// This builder + public EvaluationContextBuilder Set(string key, bool value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Set the key to the given . + /// + /// The key for the value + /// The value to set + /// This builder + public EvaluationContextBuilder Set(string key, Structure value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Set the key to the given DateTime. + /// + /// The key for the value + /// The value to set + /// This builder + public EvaluationContextBuilder Set(string key, DateTime value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Incorporate an existing context into the builder. + /// + /// Any existing keys in the builder will be replaced by keys in the context. + /// + /// + /// The context to add merge + /// This builder + public EvaluationContextBuilder Merge(EvaluationContext context) + { + foreach (var kvp in context) + { + this.Set(kvp.Key, kvp.Value); + } + + return this; + } + + /// + /// Build an immutable . + /// + /// An immutable + public EvaluationContext Build() + { + return new EvaluationContext(this._attributes.Build()); + } +} diff --git a/src/OpenFeature/Model/FlagEvaluationDetails.cs b/src/OpenFeature/Model/FlagEvaluationDetails.cs new file mode 100644 index 00000000..a08e2041 --- /dev/null +++ b/src/OpenFeature/Model/FlagEvaluationDetails.cs @@ -0,0 +1,74 @@ +using OpenFeature.Constant; + +namespace OpenFeature.Model; + +/// +/// The contract returned to the caller that describes the result of the flag evaluation process. +/// +/// Flag value type +/// +public sealed class FlagEvaluationDetails +{ + /// + /// Feature flag evaluated value + /// + public T Value { get; } + + /// + /// Feature flag key + /// + public string FlagKey { get; } + + /// + /// Error that occurred during evaluation + /// + public ErrorType ErrorType { get; } + + /// + /// Message containing additional details about an error. + /// + /// Will be if there is no error or if the provider didn't provide any additional error + /// details. + /// + /// + public string? ErrorMessage { get; } + + /// + /// Describes the reason for the outcome of the evaluation process + /// + public string? Reason { get; } + + /// + /// A variant is a semantic identifier for a value. This allows for referral to particular values without + /// necessarily including the value itself, which may be quite prohibitively large or otherwise unsuitable + /// in some cases. + /// + public string? Variant { get; } + + /// + /// A structure which supports definition of arbitrary properties, with keys of type string, and values of type boolean, string, or number. + /// + public ImmutableMetadata? FlagMetadata { get; } + + /// + /// Initializes a new instance of the class. + /// + /// Feature flag key + /// Evaluated value + /// Error + /// Reason + /// Variant + /// Error message + /// Flag metadata + public FlagEvaluationDetails(string flagKey, T value, ErrorType errorType, string? reason, string? variant, + string? errorMessage = null, ImmutableMetadata? flagMetadata = null) + { + this.Value = value; + this.FlagKey = flagKey; + this.ErrorType = errorType; + this.Reason = reason; + this.Variant = variant; + this.ErrorMessage = errorMessage; + this.FlagMetadata = flagMetadata; + } +} diff --git a/src/OpenFeature/Model/FlagEvaluationOptions.cs b/src/OpenFeature/Model/FlagEvaluationOptions.cs index 45430dcf..a261a6b3 100644 --- a/src/OpenFeature/Model/FlagEvaluationOptions.cs +++ b/src/OpenFeature/Model/FlagEvaluationOptions.cs @@ -1,44 +1,43 @@ -using System.Collections.Generic; +using System.Collections.Immutable; -namespace OpenFeature.SDK.Model +namespace OpenFeature.Model; + +/// +/// A structure containing the one or more hooks and hook hints +/// The hook and hook hints are added to the list of hooks called during the evaluation process +/// +/// Flag Evaluation Options +public sealed class FlagEvaluationOptions { /// - /// A structure containing the one or more hooks and hook hints - /// The hook and hook hints are added to the list of hooks called during the evaluation process + /// An immutable list of /// - /// Flag Evaluation Options - public class FlagEvaluationOptions - { - /// - /// A immutable list of - /// - public IReadOnlyList Hooks { get; } + public IImmutableList Hooks { get; } - /// - /// A immutable dictionary of hook hints - /// - public IReadOnlyDictionary HookHints { get; } + /// + /// An immutable dictionary of hook hints + /// + public IImmutableDictionary HookHints { get; } - /// - /// Initializes a new instance of the class. - /// - /// - /// - public FlagEvaluationOptions(IReadOnlyList hooks, IReadOnlyDictionary hookHints) - { - this.Hooks = hooks; - this.HookHints = hookHints; - } + /// + /// Initializes a new instance of the class. + /// + /// An immutable list of hooks to use during evaluation + /// Optional - a list of hints that are passed through the hook lifecycle + public FlagEvaluationOptions(IImmutableList hooks, IImmutableDictionary? hookHints = null) + { + this.Hooks = hooks; + this.HookHints = hookHints ?? ImmutableDictionary.Empty; + } - /// - /// Initializes a new instance of the class. - /// - /// - /// - public FlagEvaluationOptions(Hook hook, IReadOnlyDictionary hookHints) - { - this.Hooks = new[] { hook }; - this.HookHints = hookHints; - } + /// + /// Initializes a new instance of the class. + /// + /// A hook to use during the evaluation + /// Optional - a list of hints that are passed through the hook lifecycle + public FlagEvaluationOptions(Hook hook, ImmutableDictionary? hookHints = null) + { + this.Hooks = ImmutableList.Create(hook); + this.HookHints = hookHints ?? ImmutableDictionary.Empty; } } diff --git a/src/OpenFeature/Model/FlagEvalusationDetails.cs b/src/OpenFeature/Model/FlagEvalusationDetails.cs deleted file mode 100644 index 2b133bd6..00000000 --- a/src/OpenFeature/Model/FlagEvalusationDetails.cs +++ /dev/null @@ -1,74 +0,0 @@ -using OpenFeature.SDK.Constant; -using OpenFeature.SDK.Extension; - -namespace OpenFeature.SDK.Model -{ - /// - /// The contract returned to the caller that describes the result of the flag evaluation process. - /// - /// Flag value type - /// - public class FlagEvaluationDetails - { - /// - /// Feature flag evaluated value - /// - public T Value { get; } - - /// - /// Feature flag key - /// - public string FlagKey { get; } - - /// - /// Error that occurred during evaluation - /// - public string ErrorType { get; } - - /// - /// Describes the reason for the outcome of the evaluation process - /// - public string Reason { get; } - - /// - /// A variant is a semantic identifier for a value. This allows for referral to particular values without - /// necessarily including the value itself, which may be quite prohibitively large or otherwise unsuitable - /// in some cases. - /// - public string Variant { get; } - - /// - /// Initializes a new instance of the class. - /// - /// Feature flag key - /// Evaluated value - /// Error - /// Reason - /// Variant - public FlagEvaluationDetails(string flagKey, T value, ErrorType errorType, string reason, string variant) - { - this.Value = value; - this.FlagKey = flagKey; - this.ErrorType = errorType.GetDescription(); - this.Reason = reason; - this.Variant = variant; - } - - /// - /// Initializes a new instance of the class. - /// - /// Feature flag key - /// Evaluated value - /// Error - /// Reason - /// Variant - public FlagEvaluationDetails(string flagKey, T value, string errorType, string reason, string variant) - { - this.Value = value; - this.FlagKey = flagKey; - this.ErrorType = errorType; - this.Reason = reason; - this.Variant = variant; - } - } -} diff --git a/src/OpenFeature/Model/HookContext.cs b/src/OpenFeature/Model/HookContext.cs index 7a610272..4abc773c 100644 --- a/src/OpenFeature/Model/HookContext.cs +++ b/src/OpenFeature/Model/HookContext.cs @@ -1,69 +1,91 @@ using System; -using OpenFeature.SDK.Constant; +using OpenFeature.Constant; -namespace OpenFeature.SDK.Model +namespace OpenFeature.Model; + +/// +/// Context provided to hook execution +/// +/// Flag value type +/// +public sealed class HookContext { + private readonly SharedHookContext _shared; + /// - /// Context provided to hook execution + /// Feature flag being evaluated /// - /// Flag value type - /// - public class HookContext - { - /// - /// Feature flag being evaluated - /// - public string FlagKey { get; } + public string FlagKey => this._shared.FlagKey; + + /// + /// Default value if flag fails to be evaluated + /// + public T DefaultValue => this._shared.DefaultValue; + + /// + /// The value type of the flag + /// + public FlagValueType FlagValueType => this._shared.FlagValueType; + + /// + /// User defined evaluation context used in the evaluation process + /// + /// + public EvaluationContext EvaluationContext { get; } + + /// + /// Client metadata + /// + public ClientMetadata ClientMetadata => this._shared.ClientMetadata; - /// - /// Default value if flag fails to be evaluated - /// - public T DefaultValue { get; } + /// + /// Provider metadata + /// + public Metadata ProviderMetadata => this._shared.ProviderMetadata; - /// - /// The value type of the flag - /// - public FlagValueType FlagValueType { get; } + /// + /// Hook data + /// + public HookData Data { get; } - /// - /// User defined evaluation context used in the evaluation process - /// - /// - public EvaluationContext EvaluationContext { get; } + /// + /// Initialize a new instance of + /// + /// Feature flag key + /// Default value + /// Flag value type + /// Client metadata + /// Provider metadata + /// Evaluation context + /// When any of arguments are null + public HookContext(string? flagKey, + T defaultValue, + FlagValueType flagValueType, + ClientMetadata? clientMetadata, + Metadata? providerMetadata, + EvaluationContext? evaluationContext) + { + this._shared = new SharedHookContext( + flagKey, defaultValue, flagValueType, clientMetadata, providerMetadata); - /// - /// Client metadata - /// - public ClientMetadata ClientMetadata { get; } + this.EvaluationContext = evaluationContext ?? throw new ArgumentNullException(nameof(evaluationContext)); + this.Data = new HookData(); + } - /// - /// Provider metadata - /// - public Metadata ProviderMetadata { get; } + internal HookContext(SharedHookContext? sharedHookContext, EvaluationContext? evaluationContext, + HookData? hookData) + { + this._shared = sharedHookContext ?? throw new ArgumentNullException(nameof(sharedHookContext)); + this.EvaluationContext = evaluationContext ?? throw new ArgumentNullException(nameof(evaluationContext)); + this.Data = hookData ?? throw new ArgumentNullException(nameof(hookData)); + } - /// - /// Initialize a new instance of - /// - /// Feature flag key - /// Default value - /// Flag value type - /// Client metadata - /// Provider metadata - /// Evaluation context - /// When any of arguments are null - public HookContext(string flagKey, - T defaultValue, - FlagValueType flagValueType, - ClientMetadata clientMetadata, - Metadata providerMetadata, - EvaluationContext evaluationContext) - { - this.FlagKey = flagKey ?? throw new ArgumentNullException(nameof(flagKey)); - this.DefaultValue = defaultValue; - this.FlagValueType = flagValueType; - this.ClientMetadata = clientMetadata ?? throw new ArgumentNullException(nameof(clientMetadata)); - this.ProviderMetadata = providerMetadata ?? throw new ArgumentNullException(nameof(providerMetadata)); - this.EvaluationContext = evaluationContext ?? throw new ArgumentNullException(nameof(evaluationContext)); - } + internal HookContext WithNewEvaluationContext(EvaluationContext context) + { + return new HookContext( + this._shared, + context, + this.Data + ); } } diff --git a/src/OpenFeature/Model/ITransactionContextPropagator.cs b/src/OpenFeature/Model/ITransactionContextPropagator.cs new file mode 100644 index 00000000..3fbc43c9 --- /dev/null +++ b/src/OpenFeature/Model/ITransactionContextPropagator.cs @@ -0,0 +1,26 @@ +namespace OpenFeature.Model; + +/// +/// is responsible for persisting a transactional context +/// for the duration of a single transaction. +/// Examples of potential transaction specific context include: a user id, user agent, IP. +/// Transaction context is merged with evaluation context prior to flag evaluation. +/// +/// +/// The precedence of merging context can be seen in +/// the specification. +/// +public interface ITransactionContextPropagator +{ + /// + /// Returns the currently defined transaction context using the registered transaction context propagator. + /// + /// The current transaction context + EvaluationContext GetTransactionContext(); + + /// + /// Sets the transaction context. + /// + /// The transaction context to be set + void SetTransactionContext(EvaluationContext evaluationContext); +} diff --git a/src/OpenFeature/Model/ImmutableMetadata.cs b/src/OpenFeature/Model/ImmutableMetadata.cs new file mode 100644 index 00000000..f1d54449 --- /dev/null +++ b/src/OpenFeature/Model/ImmutableMetadata.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace OpenFeature.Model; + +/// +/// Represents immutable metadata associated with feature flags and events. +/// +/// +/// +public sealed class ImmutableMetadata +{ + private readonly ImmutableDictionary _metadata; + + /// + /// Constructor for the class. + /// + public ImmutableMetadata() + { + this._metadata = ImmutableDictionary.Empty; + } + + /// + /// Constructor for the class. + /// + /// The dictionary containing the metadata. + public ImmutableMetadata(Dictionary metadata) + { + this._metadata = metadata.ToImmutableDictionary(); + } + + /// + /// Gets the boolean value associated with the specified key. + /// + /// The key of the value to retrieve. + /// The boolean value associated with the key, or null if the key is not found. + public bool? GetBool(string key) + { + return this.GetValue(key); + } + + /// + /// Gets the integer value associated with the specified key. + /// + /// The key of the value to retrieve. + /// The integer value associated with the key, or null if the key is not found. + public int? GetInt(string key) + { + return this.GetValue(key); + } + + /// + /// Gets the double value associated with the specified key. + /// + /// The key of the value to retrieve. + /// The double value associated with the key, or null if the key is not found. + public double? GetDouble(string key) + { + return this.GetValue(key); + } + + /// + /// Gets the string value associated with the specified key. + /// + /// The key of the value to retrieve. + /// The string value associated with the key, or null if the key is not found. + public string? GetString(string key) + { + var hasValue = this._metadata.TryGetValue(key, out var value); + if (!hasValue) + { + return null; + } + + return value as string ?? null; + } + + private T? GetValue(string key) where T : struct + { + var hasValue = this._metadata.TryGetValue(key, out var value); + if (!hasValue) + { + return null; + } + + return value is T tValue ? tValue : null; + } + + internal int Count => this._metadata.Count; +} diff --git a/src/OpenFeature/Model/Metadata.cs b/src/OpenFeature/Model/Metadata.cs index ebbd43ed..44a059ef 100644 --- a/src/OpenFeature/Model/Metadata.cs +++ b/src/OpenFeature/Model/Metadata.cs @@ -1,22 +1,21 @@ -namespace OpenFeature.SDK.Model +namespace OpenFeature.Model; + +/// +/// metadata +/// +public class Metadata { /// - /// metadata + /// Gets name of instance /// - public class Metadata - { - /// - /// Gets name of instance - /// - public string Name { get; } + public string? Name { get; } - /// - /// Initializes a new instance of the class. - /// - /// Name of instance - public Metadata(string name) - { - this.Name = name; - } + /// + /// Initializes a new instance of the class. + /// + /// Name of instance + public Metadata(string? name) + { + this.Name = name; } } diff --git a/src/OpenFeature/Model/ProviderEvents.cs b/src/OpenFeature/Model/ProviderEvents.cs new file mode 100644 index 00000000..1977edb6 --- /dev/null +++ b/src/OpenFeature/Model/ProviderEvents.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using OpenFeature.Constant; + +namespace OpenFeature.Model; + +/// +/// The EventHandlerDelegate is an implementation of an Event Handler +/// +public delegate void EventHandlerDelegate(ProviderEventPayload? eventDetails); + +/// +/// Contains the payload of an OpenFeature Event. +/// +public class ProviderEventPayload +{ + /// + /// Name of the provider. + /// + public string? ProviderName { get; set; } + + /// + /// Type of the event + /// + public ProviderEventTypes Type { get; set; } + + /// + /// A message providing more information about the event. + /// + public string? Message { get; set; } + + /// + /// Optional error associated with the event. + /// + public ErrorType? ErrorType { get; set; } + + /// + /// A List of flags that have been changed. + /// + public List? FlagsChanged { get; set; } + + /// + /// Metadata information for the event. + /// + public ImmutableMetadata? EventMetadata { get; set; } +} diff --git a/src/OpenFeature/Model/ResolutionDetails.cs b/src/OpenFeature/Model/ResolutionDetails.cs index 98313f9e..a5c43aed 100644 --- a/src/OpenFeature/Model/ResolutionDetails.cs +++ b/src/OpenFeature/Model/ResolutionDetails.cs @@ -1,60 +1,73 @@ -using OpenFeature.SDK.Constant; +using OpenFeature.Constant; -namespace OpenFeature.SDK.Model -{ - /// - /// Defines the contract that the is required to return - /// Describes the details of the feature flag being evaluated - /// - /// Flag value type - /// - public class ResolutionDetails - { - /// - /// Feature flag evaluated value - /// - public T Value { get; } - - /// - /// Feature flag key - /// - public string FlagKey { get; } - - /// - /// Error that occurred during evaluation - /// - /// - public ErrorType ErrorType { get; } - - /// - /// Describes the reason for the outcome of the evaluation process - /// - /// - public string Reason { get; } - - /// - /// A variant is a semantic identifier for a value. This allows for referral to particular values without - /// necessarily including the value itself, which may be quite prohibitively large or otherwise unsuitable - /// in some cases. - /// - public string Variant { get; } - - /// - /// Initializes a new instance of the class. - /// - /// Feature flag key - /// Evaluated value - /// Error - /// Reason - /// Variant - public ResolutionDetails(string flagKey, T value, ErrorType errorType = ErrorType.None, string reason = null, - string variant = null) - { - this.Value = value; - this.FlagKey = flagKey; - this.ErrorType = errorType; - this.Reason = reason; - this.Variant = variant; - } - } +namespace OpenFeature.Model; + +/// +/// Defines the contract that the is required to return +/// Describes the details of the feature flag being evaluated +/// +/// Flag value type +/// +public sealed class ResolutionDetails +{ + /// + /// Feature flag evaluated value + /// + public T Value { get; } + + /// + /// Feature flag key + /// + public string FlagKey { get; } + + /// + /// Error that occurred during evaluation + /// + /// + public ErrorType ErrorType { get; } + + /// + /// Message containing additional details about an error. + /// + public string? ErrorMessage { get; } + + /// + /// Describes the reason for the outcome of the evaluation process + /// + /// + public string? Reason { get; } + + /// + /// A variant is a semantic identifier for a value. This allows for referral to particular values without + /// necessarily including the value itself, which may be quite prohibitively large or otherwise unsuitable + /// in some cases. + /// + public string? Variant { get; } + + /// + /// A structure which supports definition of arbitrary properties, with keys of type string, and values of type boolean, string, or number. + /// + public ImmutableMetadata? FlagMetadata { get; } + + /// + /// Initializes a new instance of the class. + /// + /// Feature flag key + /// Evaluated value + /// Error + /// Reason + /// Variant + /// Error message + /// Flag metadata + public ResolutionDetails(string flagKey, T value, ErrorType errorType = ErrorType.None, string? reason = null, + string? variant = null, string? errorMessage = null, ImmutableMetadata? flagMetadata = null) + { + this.Value = value; + this.FlagKey = flagKey; + this.ErrorType = errorType; + this.Reason = reason; + this.Variant = variant; + this.ErrorMessage = errorMessage; + this.FlagMetadata = flagMetadata; + } } diff --git a/src/OpenFeature/Model/Structure.cs b/src/OpenFeature/Model/Structure.cs new file mode 100644 index 00000000..9807ec45 --- /dev/null +++ b/src/OpenFeature/Model/Structure.cs @@ -0,0 +1,123 @@ +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; + +namespace OpenFeature.Model; + +/// +/// Structure represents a map of Values +/// +public sealed class Structure : IEnumerable> +{ + private readonly ImmutableDictionary _attributes; + + /// + /// Internal constructor for use by the builder. + /// + internal Structure(ImmutableDictionary attributes) + { + this._attributes = attributes; + } + + /// + /// Private constructor for creating an empty . + /// + private Structure() + { + this._attributes = ImmutableDictionary.Empty; + } + + /// + /// An empty structure. + /// + public static Structure Empty { get; } = new Structure(); + + /// + /// Creates a new structure with the supplied attributes + /// + /// + public Structure(IDictionary attributes) + { + this._attributes = ImmutableDictionary.CreateRange(attributes); + } + + /// + /// Gets the Value at the specified key + /// + /// The key of the value to be retrieved + /// + public Value GetValue(string key) => this._attributes[key]; + + /// + /// Bool indicating if the specified key exists in the structure + /// + /// The key of the value to be retrieved + /// indicating the presence of the key. + public bool ContainsKey(string key) => this._attributes.ContainsKey(key); + + /// + /// Gets the value associated with the specified key by mutating the supplied value. + /// + /// The key of the value to be retrieved + /// value to be mutated + /// indicating the presence of the key. + public bool TryGetValue(string key, out Value? value) => this._attributes.TryGetValue(key, out value); + + /// + /// Gets all values as a Dictionary + /// + /// New representation of this Structure + public IImmutableDictionary AsDictionary() + { + return this._attributes; + } + + /// + /// Return the value at the supplied index + /// + /// The key of the value to be retrieved + public Value this[string key] + { + get => this._attributes[key]; + } + + /// + /// Return a list containing all the keys in this structure + /// + public IImmutableList Keys => this._attributes.Keys.ToImmutableList(); + + /// + /// Return an enumerable containing all the values in this structure + /// + public IImmutableList Values => this._attributes.Values.ToImmutableList(); + + /// + /// Return a count of all values + /// + public int Count => this._attributes.Count; + + /// + /// Return an enumerator for all values + /// + /// + public IEnumerator> GetEnumerator() + { + return this._attributes.GetEnumerator(); + } + + /// + /// Get a builder which can build a . + /// + /// The builder + public static StructureBuilder Builder() + { + return new StructureBuilder(); + } + + [ExcludeFromCodeCoverage] + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } +} diff --git a/src/OpenFeature/Model/StructureBuilder.cs b/src/OpenFeature/Model/StructureBuilder.cs new file mode 100644 index 00000000..0cc922ac --- /dev/null +++ b/src/OpenFeature/Model/StructureBuilder.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace OpenFeature.Model; + +/// +/// A builder which allows the specification of attributes for a . +/// +/// A object is intended for use by a single thread and should not be used from +/// multiple threads. Once a has been created it is immutable and safe for use from +/// multiple threads. +/// +/// +public sealed class StructureBuilder +{ + private readonly ImmutableDictionary.Builder _attributes = + ImmutableDictionary.CreateBuilder(); + + /// + /// Internal to only allow direct creation by . + /// + internal StructureBuilder() { } + + /// + /// Set the key to the given . + /// + /// The key for the value + /// The value to set + /// This builder + public StructureBuilder Set(string key, Value value) + { + // Remove the attribute. Will not throw an exception if not present. + this._attributes.Remove(key); + this._attributes.Add(key, value); + return this; + } + + /// + /// Set the key to the given string. + /// + /// The key for the value + /// The value to set + /// This builder + public StructureBuilder Set(string key, string value) + { + this.Set(key, new Value(value)); + return this; + } + + /// + /// Set the key to the given int. + /// + /// The key for the value + /// The value to set + /// This builder + public StructureBuilder Set(string key, int value) + { + this.Set(key, new Value(value)); + return this; + } + + /// + /// Set the key to the given double. + /// + /// The key for the value + /// The value to set + /// This builder + public StructureBuilder Set(string key, double value) + { + this.Set(key, new Value(value)); + return this; + } + + /// + /// Set the key to the given long. + /// + /// The key for the value + /// The value to set + /// This builder + public StructureBuilder Set(string key, long value) + { + this.Set(key, new Value(value)); + return this; + } + + /// + /// Set the key to the given bool. + /// + /// The key for the value + /// The value to set + /// This builder + public StructureBuilder Set(string key, bool value) + { + this.Set(key, new Value(value)); + return this; + } + + /// + /// Set the key to the given . + /// + /// The key for the value + /// The value to set + /// This builder + public StructureBuilder Set(string key, Structure value) + { + this.Set(key, new Value(value)); + return this; + } + + /// + /// Set the key to the given DateTime. + /// + /// The key for the value + /// The value to set + /// This builder + public StructureBuilder Set(string key, DateTime value) + { + this.Set(key, new Value(value)); + return this; + } + + /// + /// Set the key to the given list. + /// + /// The key for the value + /// The value to set + /// This builder + public StructureBuilder Set(string key, IList value) + { + this.Set(key, new Value(value)); + return this; + } + + /// + /// Build an immutable / + /// + /// The built + public Structure Build() + { + return new Structure(this._attributes.ToImmutable()); + } +} diff --git a/src/OpenFeature/Model/TrackingEventDetails.cs b/src/OpenFeature/Model/TrackingEventDetails.cs new file mode 100644 index 00000000..0d342cc1 --- /dev/null +++ b/src/OpenFeature/Model/TrackingEventDetails.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace OpenFeature.Model; + +/// +/// The `tracking event details` structure defines optional data pertinent to a particular `tracking event`. +/// +/// +public sealed class TrackingEventDetails +{ + /// + ///A predefined value field for the tracking details. + /// + public readonly double? Value; + + private readonly Structure _structure; + + /// + /// Internal constructor used by the builder. + /// + /// + /// + internal TrackingEventDetails(Structure content, double? value) + { + this.Value = value; + this._structure = content; + } + + + /// + /// Private constructor for making an empty . + /// + private TrackingEventDetails() + { + this._structure = Structure.Empty; + this.Value = null; + } + + /// + /// Empty tracking event details. + /// + public static TrackingEventDetails Empty { get; } = new(); + + + /// + /// Gets the Value at the specified key + /// + /// The key of the value to be retrieved + /// The associated with the key + /// + /// Thrown when the context does not contain the specified key + /// + /// + /// Thrown when the key is + /// + public Value GetValue(string key) => this._structure.GetValue(key); + + /// + /// Bool indicating if the specified key exists in the evaluation context + /// + /// The key of the value to be checked + /// indicating the presence of the key + /// + /// Thrown when the key is + /// + public bool ContainsKey(string key) => this._structure.ContainsKey(key); + + /// + /// Gets the value associated with the specified key + /// + /// The or if the key was not present + /// The key of the value to be retrieved + /// indicating the presence of the key + /// + /// Thrown when the key is + /// + public bool TryGetValue(string key, out Value? value) => this._structure.TryGetValue(key, out value); + + /// + /// Gets all values as a Dictionary + /// + /// New representation of this Structure + public IImmutableDictionary AsDictionary() + { + return this._structure.AsDictionary(); + } + + /// + /// Return a count of all values + /// + public int Count => this._structure.Count; + + /// + /// Return an enumerator for all values + /// + /// An enumerator for all values + public IEnumerator> GetEnumerator() + { + return this._structure.GetEnumerator(); + } + + /// + /// Get a builder which can build an . + /// + /// The builder + public static TrackingEventDetailsBuilder Builder() + { + return new TrackingEventDetailsBuilder(); + } +} diff --git a/src/OpenFeature/Model/TrackingEventDetailsBuilder.cs b/src/OpenFeature/Model/TrackingEventDetailsBuilder.cs new file mode 100644 index 00000000..6520ab3e --- /dev/null +++ b/src/OpenFeature/Model/TrackingEventDetailsBuilder.cs @@ -0,0 +1,158 @@ +using System; + +namespace OpenFeature.Model; + +/// +/// A builder which allows the specification of attributes for an . +/// +/// A object is intended for use by a single thread and should not be used +/// from multiple threads. Once an has been created it is immutable and safe for use +/// from multiple threads. +/// +/// +public sealed class TrackingEventDetailsBuilder +{ + private readonly StructureBuilder _attributes = Structure.Builder(); + private double? _value; + + /// + /// Internal to only allow direct creation by . + /// + internal TrackingEventDetailsBuilder() { } + + /// + /// Set the predefined value field for the tracking details. + /// + /// + /// + public TrackingEventDetailsBuilder SetValue(double? value) + { + this._value = value; + return this; + } + + /// + /// Set the key to the given . + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, Value value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Set the key to the given string. + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, string value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Set the key to the given int. + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, int value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Set the key to the given double. + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, double value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Set the key to the given long. + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, long value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Set the key to the given bool. + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, bool value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Set the key to the given . + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, Structure value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Set the key to the given DateTime. + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, DateTime value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Incorporate existing tracking details into the builder. + /// + /// Any existing keys in the builder will be replaced by keys in the tracking details, including the Value set + /// through . + /// + /// + /// The tracking details to add merge + /// This builder + public TrackingEventDetailsBuilder Merge(TrackingEventDetails trackingDetails) + { + this._value = trackingDetails.Value; + foreach (var kvp in trackingDetails) + { + this.Set(kvp.Key, kvp.Value); + } + + return this; + } + + /// + /// Build an immutable . + /// + /// An immutable + public TrackingEventDetails Build() + { + return new TrackingEventDetails(this._attributes.Build(), this._value); + } +} diff --git a/src/OpenFeature/Model/Value.cs b/src/OpenFeature/Model/Value.cs new file mode 100644 index 00000000..2f75eca3 --- /dev/null +++ b/src/OpenFeature/Model/Value.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace OpenFeature.Model; + +/// +/// Values serve as a return type for provider objects. Providers may deal in JSON, protobuf, XML or some other data-interchange format. +/// This intermediate representation provides a good medium of exchange. +/// +public sealed class Value +{ + private readonly object? _innerValue; + + /// + /// Creates a Value with the inner value set to null + /// + public Value() => this._innerValue = null; + + /// + /// Creates a Value with the inner set to the object + /// + /// The object to set as the inner value + public Value(Object value) + { + if (value is IList list) + { + value = list.ToImmutableList(); + } + // integer is a special case, convert those. + this._innerValue = value is int ? Convert.ToDouble(value) : value; + if (!(this.IsNull + || this.IsBoolean + || this.IsString + || this.IsNumber + || this.IsStructure + || this.IsList + || this.IsDateTime)) + { + throw new ArgumentException("Invalid value type: " + value.GetType()); + } + } + + + /// + /// Creates a Value with the inner value to the inner value of the value param + /// + /// Value type + public Value(Value value) => this._innerValue = value._innerValue; + + /// + /// Creates a Value with the inner set to bool type + /// + /// Bool type + public Value(bool value) => this._innerValue = value; + + /// + /// Creates a Value by converting value to a double + /// + /// Int type + public Value(int value) => this._innerValue = Convert.ToDouble(value); + + /// + /// Creates a Value with the inner set to double type + /// + /// Double type + public Value(double value) => this._innerValue = value; + + /// + /// Creates a Value with the inner set to string type + /// + /// String type + public Value(string value) => this._innerValue = value; + + /// + /// Creates a Value with the inner set to structure type + /// + /// Structure type + public Value(Structure value) => this._innerValue = value; + + /// + /// Creates a Value with the inner set to list type + /// + /// List type + public Value(IList value) => this._innerValue = value.ToImmutableList(); + + /// + /// Creates a Value with the inner set to DateTime type + /// + /// DateTime type + public Value(DateTime value) => this._innerValue = value; + + /// + /// Determines if inner value is null + /// + /// True if value is null + public bool IsNull => this._innerValue is null; + + /// + /// Determines if inner value is bool + /// + /// True if value is bool + public bool IsBoolean => this._innerValue is bool; + + /// + /// Determines if inner value is numeric + /// + /// True if value is double + public bool IsNumber => this._innerValue is double; + + /// + /// Determines if inner value is string + /// + /// True if value is string + public bool IsString => this._innerValue is string; + + /// + /// Determines if inner value is Structure + /// + /// True if value is Structure + public bool IsStructure => this._innerValue is Structure; + + /// + /// Determines if inner value is list + /// + /// True if value is list + public bool IsList => this._innerValue is IImmutableList; + + /// + /// Determines if inner value is DateTime + /// + /// True if value is DateTime + public bool IsDateTime => this._innerValue is DateTime; + + /// + /// Returns the underlying inner value as an object. Returns null if the inner value is null. + /// + /// Value as object + public object? AsObject => this._innerValue; + + /// + /// Returns the underlying int value. + /// Value will be null if it isn't an integer + /// + /// Value as int + public int? AsInteger => this.IsNumber ? Convert.ToInt32((double?)this._innerValue) : null; + + /// + /// Returns the underlying bool value. + /// Value will be null if it isn't a bool + /// + /// Value as bool + public bool? AsBoolean => this.IsBoolean ? (bool?)this._innerValue : null; + + /// + /// Returns the underlying double value. + /// Value will be null if it isn't a double + /// + /// Value as int + public double? AsDouble => this.IsNumber ? (double?)this._innerValue : null; + + /// + /// Returns the underlying string value. + /// Value will be null if it isn't a string + /// + /// Value as string + public string? AsString => this.IsString ? (string?)this._innerValue : null; + + /// + /// Returns the underlying Structure value. + /// Value will be null if it isn't a Structure + /// + /// Value as Structure + public Structure? AsStructure => this.IsStructure ? (Structure?)this._innerValue : null; + + /// + /// Returns the underlying List value. + /// Value will be null if it isn't a List + /// + /// Value as List + public IImmutableList? AsList => this.IsList ? (IImmutableList?)this._innerValue : null; + + /// + /// Returns the underlying DateTime value. + /// Value will be null if it isn't a DateTime + /// + /// Value as DateTime + public DateTime? AsDateTime => this.IsDateTime ? (DateTime?)this._innerValue : null; +} diff --git a/src/OpenFeature/NoOpProvider.cs b/src/OpenFeature/NoOpProvider.cs index f9e0dde7..20973365 100644 --- a/src/OpenFeature/NoOpProvider.cs +++ b/src/OpenFeature/NoOpProvider.cs @@ -1,46 +1,51 @@ +using System.Threading; using System.Threading.Tasks; -using OpenFeature.SDK.Constant; -using OpenFeature.SDK.Model; +using OpenFeature.Constant; +using OpenFeature.Model; -namespace OpenFeature.SDK +namespace OpenFeature; + +internal sealed class NoOpFeatureProvider : FeatureProvider { - internal class NoOpFeatureProvider : IFeatureProvider + private readonly Metadata _metadata = new Metadata(NoOpProvider.NoOpProviderName); + + public override Metadata GetMetadata() + { + return this._metadata; + } + + public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + private static ResolutionDetails NoOpResponse(string flagKey, T defaultValue) { - private readonly Metadata _metadata = new Metadata(NoOpProvider.NoOpProviderName); - - public Metadata GetMetadata() - { - return this._metadata; - } - - public Task> ResolveBooleanValue(string flagKey, bool defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null) - { - return Task.FromResult(NoOpResponse(flagKey, defaultValue)); - } - - public Task> ResolveStringValue(string flagKey, string defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null) - { - return Task.FromResult(NoOpResponse(flagKey, defaultValue)); - } - - public Task> ResolveNumberValue(string flagKey, int defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null) - { - return Task.FromResult(NoOpResponse(flagKey, defaultValue)); - } - - public Task> ResolveStructureValue(string flagKey, T defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null) - { - return Task.FromResult(NoOpResponse(flagKey, defaultValue)); - } - - private static ResolutionDetails NoOpResponse(string flagKey, T defaultValue) - { - return new ResolutionDetails( - flagKey, - defaultValue, - reason: NoOpProvider.ReasonNoOp, - variant: NoOpProvider.Variant - ); - } + return new ResolutionDetails( + flagKey, + defaultValue, + reason: NoOpProvider.ReasonNoOp, + variant: NoOpProvider.Variant + ); } } diff --git a/src/OpenFeature/NoOpTransactionContextPropagator.cs b/src/OpenFeature/NoOpTransactionContextPropagator.cs new file mode 100644 index 00000000..70f57cdc --- /dev/null +++ b/src/OpenFeature/NoOpTransactionContextPropagator.cs @@ -0,0 +1,15 @@ +using OpenFeature.Model; + +namespace OpenFeature; + +internal class NoOpTransactionContextPropagator : ITransactionContextPropagator +{ + public EvaluationContext GetTransactionContext() + { + return EvaluationContext.Empty; + } + + public void SetTransactionContext(EvaluationContext evaluationContext) + { + } +} diff --git a/src/OpenFeature/OpenFeature.cs b/src/OpenFeature/OpenFeature.cs deleted file mode 100644 index 66f04612..00000000 --- a/src/OpenFeature/OpenFeature.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Collections.Generic; -using Microsoft.Extensions.Logging; -using OpenFeature.SDK.Model; - -namespace OpenFeature.SDK -{ - /// - /// The evaluation API allows for the evaluation of feature flag values, independent of any flag control plane or vendor. - /// In the absence of a provider the evaluation API uses the "No-op provider", which simply returns the supplied default flag value. - /// - /// - public sealed class OpenFeature - { - private EvaluationContext _evaluationContext = new EvaluationContext(); - private IFeatureProvider _featureProvider = new NoOpFeatureProvider(); - private readonly List _hooks = new List(); - - /// - /// Singleton instance of OpenFeature - /// - public static OpenFeature Instance { get; } = new OpenFeature(); - - // Explicit static constructor to tell C# compiler - // not to mark type as beforefieldinit - // IE Lazy way of ensuring this is thread safe without using locks - static OpenFeature() { } - private OpenFeature() { } - - /// - /// Sets the feature provider - /// - /// Implementation of - public void SetProvider(IFeatureProvider featureProvider) => this._featureProvider = featureProvider; - - /// - /// Gets the feature provider - /// - /// - public IFeatureProvider GetProvider() => this._featureProvider; - - /// - /// Gets providers metadata - /// - /// - public Metadata GetProviderMetadata() => this._featureProvider.GetMetadata(); - - /// - /// Create a new instance of using the current provider - /// - /// Name of client - /// Version of client - /// Logger instance used by client - /// - public FeatureClient GetClient(string name = null, string version = null, ILogger logger = null) => - new FeatureClient(this._featureProvider, name, version, logger); - - /// - /// Appends list of hooks to global hooks list - /// - /// A list of - public void AddHooks(IEnumerable hooks) => this._hooks.AddRange(hooks); - - /// - /// Adds a hook to global hooks list - /// - /// A list of - public void AddHooks(Hook hook) => this._hooks.Add(hook); - - /// - /// Returns the global immutable hooks list - /// - /// A immutable list of - public IReadOnlyList GetHooks() => this._hooks.AsReadOnly(); - - /// - /// Removes all hooks from global hooks list - /// - public void ClearHooks() => this._hooks.Clear(); - - /// - /// Sets the global - /// - /// - public void SetContext(EvaluationContext context) => this._evaluationContext = context; - - /// - /// Gets the global - /// - /// - public EvaluationContext GetContext() => this._evaluationContext; - } -} diff --git a/src/OpenFeature/OpenFeature.csproj b/src/OpenFeature/OpenFeature.csproj index ca68ee2e..c47b109d 100644 --- a/src/OpenFeature/OpenFeature.csproj +++ b/src/OpenFeature/OpenFeature.csproj @@ -1,17 +1,22 @@ - netstandard2.0;net462 + net8.0;net9.0;netstandard2.0;net462 + OpenFeature + README.md - + + - - <_Parameter1>OpenFeature.Tests - + + + + + diff --git a/src/OpenFeature/OpenFeatureClient.cs b/src/OpenFeature/OpenFeatureClient.cs index e925dd58..98aae19f 100644 --- a/src/OpenFeature/OpenFeatureClient.cs +++ b/src/OpenFeature/OpenFeatureClient.cs @@ -1,296 +1,343 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using OpenFeature.SDK.Constant; -using OpenFeature.SDK.Error; -using OpenFeature.SDK.Extension; -using OpenFeature.SDK.Model; +using OpenFeature.Constant; +using OpenFeature.Error; +using OpenFeature.Extension; +using OpenFeature.Model; -namespace OpenFeature.SDK +namespace OpenFeature; + +/// +/// +/// +public sealed partial class FeatureClient : IFeatureClient { + private readonly ClientMetadata _metadata; + private readonly ConcurrentStack _hooks = new ConcurrentStack(); + private readonly ILogger _logger; + private readonly Func _providerAccessor; + private EvaluationContext _evaluationContext; + + private readonly object _evaluationContextLock = new object(); + /// - /// + /// Get a provider and an associated typed flag resolution method. + /// + /// The global provider could change between two accesses, so in order to safely get provider information we + /// must first alias it and then use that alias to access everything we need. + /// /// - public sealed class FeatureClient : IFeatureClient + /// + /// This method should return the desired flag resolution method from the given provider reference. + /// + /// The type of the resolution method + /// A tuple containing a resolution method and the provider it came from. + private (Func>>, FeatureProvider) + ExtractProvider( + Func>>> method) { - private readonly ClientMetadata _metadata; - private readonly IFeatureProvider _featureProvider; - private readonly List _hooks = new List(); - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// Feature provider used by client - /// Name of client - /// Version of client - /// Logger used by client - /// Throws if any of the required parameters are null - public FeatureClient(IFeatureProvider featureProvider, string name, string version, ILogger logger = null) + // Alias the provider reference so getting the method and returning the provider are + // guaranteed to be the same object. + var provider = Api.Instance.GetProvider(this._metadata.Name!); + + return (method(provider), provider); + } + + /// + public ProviderStatus ProviderStatus => this._providerAccessor.Invoke().Status; + + /// + public EvaluationContext GetContext() + { + lock (this._evaluationContextLock) { - this._featureProvider = featureProvider ?? throw new ArgumentNullException(nameof(featureProvider)); - this._metadata = new ClientMetadata(name, version); - this._logger = logger ?? new Logger(new NullLoggerFactory()); + return this._evaluationContext; } + } - /// - /// Gets client metadata - /// - /// Client metadata - public ClientMetadata GetMetadata() => this._metadata; - - /// - /// Add hook to client - /// - /// Hook that implements the interface - public void AddHooks(Hook hook) => this._hooks.Add(hook); - - /// - /// Appends hooks to client - /// - /// A list of Hooks that implement the interface - public void AddHooks(IEnumerable hooks) => this._hooks.AddRange(hooks); - - /// - /// Return a immutable list of hooks that are registered against the client - /// - /// A list of immutable hooks - public IReadOnlyList GetHooks() => this._hooks.ToList().AsReadOnly(); - - /// - /// Removes all hooks from the client - /// - public void ClearHooks() => this._hooks.Clear(); - - /// - /// Resolves a boolean feature flag - /// - /// Feature flag key - /// Default value - /// Evaluation Context - /// Flag Evaluation Options - /// Resolved flag details - public async Task GetBooleanValue(string flagKey, bool defaultValue, EvaluationContext context = null, - FlagEvaluationOptions config = null) => - (await this.GetBooleanDetails(flagKey, defaultValue, context, config)).Value; - - /// - /// Resolves a boolean feature flag - /// - /// Feature flag key - /// Default value - /// Evaluation Context - /// Flag Evaluation Options - /// Resolved flag details - public async Task> GetBooleanDetails(string flagKey, bool defaultValue, - EvaluationContext context = null, FlagEvaluationOptions config = null) => - await this.EvaluateFlag(this._featureProvider.ResolveBooleanValue, FlagValueType.Boolean, flagKey, - defaultValue, context, config); - - /// - /// Resolves a string feature flag - /// - /// Feature flag key - /// Default value - /// Evaluation Context - /// Flag Evaluation Options - /// Resolved flag details - public async Task GetStringValue(string flagKey, string defaultValue, EvaluationContext context = null, - FlagEvaluationOptions config = null) => - (await this.GetStringDetails(flagKey, defaultValue, context, config)).Value; - - /// - /// Resolves a string feature flag - /// - /// Feature flag key - /// Default value - /// Evaluation Context - /// Flag Evaluation Options - /// Resolved flag details - public async Task> GetStringDetails(string flagKey, string defaultValue, - EvaluationContext context = null, FlagEvaluationOptions config = null) => - await this.EvaluateFlag(this._featureProvider.ResolveStringValue, FlagValueType.String, flagKey, - defaultValue, context, config); - - /// - /// Resolves a number feature flag - /// - /// Feature flag key - /// Default value - /// Evaluation Context - /// Flag Evaluation Options - /// Resolved flag details - public async Task GetNumberValue(string flagKey, int defaultValue, EvaluationContext context = null, - FlagEvaluationOptions config = null) => - (await this.GetNumberDetails(flagKey, defaultValue, context, config)).Value; - - /// - /// Resolves a number feature flag - /// - /// Feature flag key - /// Default value - /// Evaluation Context - /// Flag Evaluation Options - /// Resolved flag details - public async Task> GetNumberDetails(string flagKey, int defaultValue, - EvaluationContext context = null, FlagEvaluationOptions config = null) => - await this.EvaluateFlag(this._featureProvider.ResolveNumberValue, FlagValueType.Number, flagKey, - defaultValue, context, config); - - /// - /// Resolves a object feature flag - /// - /// Feature flag key - /// Default value - /// Evaluation Context - /// Flag Evaluation Options - /// Resolved flag details - public async Task GetObjectValue(string flagKey, T defaultValue, EvaluationContext context = null, - FlagEvaluationOptions config = null) => - (await this.GetObjectDetails(flagKey, defaultValue, context, config)).Value; - - /// - /// Resolves a object feature flag - /// - /// Feature flag key - /// Default value - /// Evaluation Context - /// Flag Evaluation Options - /// Resolved flag details - public async Task> GetObjectDetails(string flagKey, T defaultValue, - EvaluationContext context = null, FlagEvaluationOptions config = null) => - await this.EvaluateFlag(this._featureProvider.ResolveStructureValue, FlagValueType.Object, flagKey, - defaultValue, context, config); - - private async Task> EvaluateFlag( - Func>> resolveValueDelegate, - FlagValueType flagValueType, string flagKey, T defaultValue, EvaluationContext context = null, - FlagEvaluationOptions options = null) + /// + public void SetContext(EvaluationContext? context) + { + lock (this._evaluationContextLock) { - // New up a evaluation context if one was not provided. - if (context == null) - { - context = new EvaluationContext(); - } + this._evaluationContext = context ?? EvaluationContext.Empty; + } + } - var evaluationContext = OpenFeature.Instance.GetContext(); - evaluationContext.Merge(context); - - var allHooks = new List() - .Concat(OpenFeature.Instance.GetHooks()) - .Concat(this._hooks) - .Concat(options?.Hooks ?? Enumerable.Empty()) - .ToList() - .AsReadOnly(); - - var allHooksReversed = allHooks - .AsEnumerable() - .Reverse() - .ToList() - .AsReadOnly(); - - var hookContext = new HookContext( - flagKey, - defaultValue, - flagValueType, this._metadata, - OpenFeature.Instance.GetProviderMetadata(), - evaluationContext - ); - - FlagEvaluationDetails evaluation; - try - { - await this.TriggerBeforeHooks(allHooks, hookContext, options); + /// + /// Initializes a new instance of the class. + /// + /// Function to retrieve current provider + /// Name of client + /// Version of client + /// Logger used by client + /// Context given to this client + /// Throws if any of the required parameters are null + internal FeatureClient(Func providerAccessor, string? name, string? version, ILogger? logger = null, EvaluationContext? context = null) + { + this._metadata = new ClientMetadata(name, version); + this._logger = logger ?? NullLogger.Instance; + this._evaluationContext = context ?? EvaluationContext.Empty; + this._providerAccessor = providerAccessor; + } + + /// + public ClientMetadata GetMetadata() => this._metadata; + + /// + /// Add hook to client + /// + /// Hooks which are dependent on each other should be provided in a collection + /// using the . + /// + /// + /// Hook that implements the interface + public void AddHooks(Hook hook) => this._hooks.Push(hook); + + /// + public void AddHandler(ProviderEventTypes eventType, EventHandlerDelegate handler) + { + Api.Instance.AddClientHandler(this._metadata.Name!, eventType, handler); + } + + /// + public void RemoveHandler(ProviderEventTypes type, EventHandlerDelegate handler) + { + Api.Instance.RemoveClientHandler(this._metadata.Name!, type, handler); + } + + /// + public void AddHooks(IEnumerable hooks) +#if NET7_0_OR_GREATER + => this._hooks.PushRange(hooks as Hook[] ?? hooks.ToArray()); +#else + { + // See: https://github.com/dotnet/runtime/issues/62121 + if (hooks is Hook[] array) + { + if (array.Length > 0) + this._hooks.PushRange(array); + + return; + } + + array = hooks.ToArray(); + + if (array.Length > 0) + this._hooks.PushRange(array); + } +#endif + + /// + public IEnumerable GetHooks() => this._hooks.Reverse(); + + /// + /// Removes all hooks from the client + /// + public void ClearHooks() => this._hooks.Clear(); + + /// + public async Task GetBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, + FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => + (await this.GetBooleanDetailsAsync(flagKey, defaultValue, context, config, cancellationToken).ConfigureAwait(false)).Value; + + /// + public async Task> GetBooleanDetailsAsync(string flagKey, bool defaultValue, + EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => + await this.EvaluateFlagAsync(this.ExtractProvider(provider => provider.ResolveBooleanValueAsync), + FlagValueType.Boolean, flagKey, + defaultValue, context, config, cancellationToken).ConfigureAwait(false); - evaluation = - (await resolveValueDelegate.Invoke(flagKey, defaultValue, hookContext.EvaluationContext, options)) - .ToFlagEvaluationDetails(); + /// + public async Task GetStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, + FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => + (await this.GetStringDetailsAsync(flagKey, defaultValue, context, config, cancellationToken).ConfigureAwait(false)).Value; - await this.TriggerAfterHooks(allHooksReversed, hookContext, evaluation, options); + /// + public async Task> GetStringDetailsAsync(string flagKey, string defaultValue, + EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => + await this.EvaluateFlagAsync(this.ExtractProvider(provider => provider.ResolveStringValueAsync), + FlagValueType.String, flagKey, + defaultValue, context, config, cancellationToken).ConfigureAwait(false); + + /// + public async Task GetIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, + FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => + (await this.GetIntegerDetailsAsync(flagKey, defaultValue, context, config, cancellationToken).ConfigureAwait(false)).Value; + + /// + public async Task> GetIntegerDetailsAsync(string flagKey, int defaultValue, + EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => + await this.EvaluateFlagAsync(this.ExtractProvider(provider => provider.ResolveIntegerValueAsync), + FlagValueType.Number, flagKey, + defaultValue, context, config, cancellationToken).ConfigureAwait(false); + + /// + public async Task GetDoubleValueAsync(string flagKey, double defaultValue, + EvaluationContext? context = null, + FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => + (await this.GetDoubleDetailsAsync(flagKey, defaultValue, context, config, cancellationToken).ConfigureAwait(false)).Value; + + /// + public async Task> GetDoubleDetailsAsync(string flagKey, double defaultValue, + EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => + await this.EvaluateFlagAsync(this.ExtractProvider(provider => provider.ResolveDoubleValueAsync), + FlagValueType.Number, flagKey, + defaultValue, context, config, cancellationToken).ConfigureAwait(false); + + /// + public async Task GetObjectValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, + FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => + (await this.GetObjectDetailsAsync(flagKey, defaultValue, context, config, cancellationToken).ConfigureAwait(false)).Value; + + /// + public async Task> GetObjectDetailsAsync(string flagKey, Value defaultValue, + EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => + await this.EvaluateFlagAsync(this.ExtractProvider(provider => provider.ResolveStructureValueAsync), + FlagValueType.Object, flagKey, + defaultValue, context, config, cancellationToken).ConfigureAwait(false); + + private async Task> EvaluateFlagAsync( + (Func>>, FeatureProvider) providerInfo, + FlagValueType flagValueType, string flagKey, T defaultValue, EvaluationContext? context = null, + FlagEvaluationOptions? options = null, + CancellationToken cancellationToken = default) + { + var resolveValueDelegate = providerInfo.Item1; + var provider = providerInfo.Item2; + + // New up an evaluation context if one was not provided. + context ??= EvaluationContext.Empty; + + // merge api, client, transaction and invocation context + var evaluationContextBuilder = EvaluationContext.Builder(); + evaluationContextBuilder.Merge(Api.Instance.GetContext()); // API context + evaluationContextBuilder.Merge(this.GetContext()); // Client context + evaluationContextBuilder.Merge(Api.Instance.GetTransactionContext()); // Transaction context + evaluationContextBuilder.Merge(context); // Invocation context + + var allHooks = ImmutableList.CreateBuilder() + .Concat(Api.Instance.GetHooks()) + .Concat(this.GetHooks()) + .Concat(options?.Hooks ?? Enumerable.Empty()) + .Concat(provider.GetProviderHooks()) + .ToImmutableList(); + + var sharedHookContext = new SharedHookContext( + flagKey, + defaultValue, + flagValueType, + this._metadata, + provider.GetMetadata() + ); + + FlagEvaluationDetails? evaluation = null; + var hookRunner = new HookRunner(allHooks, evaluationContextBuilder.Build(), sharedHookContext, + this._logger); + + try + { + var evaluationContextFromHooks = await hookRunner.TriggerBeforeHooksAsync(options?.HookHints, cancellationToken) + .ConfigureAwait(false); + + // short circuit evaluation entirely if provider is in a bad state + if (provider.Status == ProviderStatus.NotReady) + { + throw new ProviderNotReadyException("Provider has not yet completed initialization."); } - catch (FeatureProviderException ex) + else if (provider.Status == ProviderStatus.Fatal) { - this._logger.LogError(ex, "Error while evaluating flag {FlagKey}. Error {ErrorType}", flagKey, - ex.ErrorDescription); - evaluation = new FlagEvaluationDetails(flagKey, defaultValue, ex.ErrorDescription, Reason.Error, - string.Empty); - await this.TriggerErrorHooks(allHooksReversed, hookContext, ex, options); + throw new ProviderFatalException("Provider is in an irrecoverable error state."); } - catch (Exception ex) + + evaluation = + (await resolveValueDelegate + .Invoke(flagKey, defaultValue, evaluationContextFromHooks, cancellationToken) + .ConfigureAwait(false)) + .ToFlagEvaluationDetails(); + + if (evaluation.ErrorType == ErrorType.None) { - this._logger.LogError(ex, "Error while evaluating flag {FlagKey}", flagKey); - var errorCode = ex is InvalidCastException ? ErrorType.TypeMismatch : ErrorType.General; - evaluation = new FlagEvaluationDetails(flagKey, defaultValue, errorCode, Reason.Error, string.Empty); - await this.TriggerErrorHooks(allHooksReversed, hookContext, ex, options); + await hookRunner.TriggerAfterHooksAsync( + evaluation, + options?.HookHints, + cancellationToken + ).ConfigureAwait(false); } - finally + else { - await this.TriggerFinallyHooks(allHooksReversed, hookContext, options); + var exception = new FeatureProviderException(evaluation.ErrorType, evaluation.ErrorMessage); + this.FlagEvaluationErrorWithDescription(flagKey, evaluation.ErrorType.GetDescription(), exception); + await hookRunner.TriggerErrorHooksAsync(exception, options?.HookHints, cancellationToken) + .ConfigureAwait(false); } - - return evaluation; } - - private async Task TriggerBeforeHooks(IReadOnlyList hooks, HookContext context, - FlagEvaluationOptions options) + catch (FeatureProviderException ex) { - foreach (var hook in hooks) - { - var resp = await hook.Before(context, options?.HookHints); - if (resp != null) - { - context.EvaluationContext.Merge(resp); - } - else - { - this._logger.LogDebug("Hook {HookName} returned null, nothing to merge back into context", - hook.GetType().Name); - } - } + this.FlagEvaluationErrorWithDescription(flagKey, ex.ErrorType.GetDescription(), ex); + evaluation = new FlagEvaluationDetails(flagKey, defaultValue, ex.ErrorType, Reason.Error, + string.Empty, ex.Message); + await hookRunner.TriggerErrorHooksAsync(ex, options?.HookHints, cancellationToken) + .ConfigureAwait(false); } - - private async Task TriggerAfterHooks(IReadOnlyList hooks, HookContext context, - FlagEvaluationDetails evaluationDetails, FlagEvaluationOptions options) + catch (Exception ex) { - foreach (var hook in hooks) - { - await hook.After(context, evaluationDetails, options?.HookHints); - } + var errorCode = ex is InvalidCastException ? ErrorType.TypeMismatch : ErrorType.General; + evaluation = new FlagEvaluationDetails(flagKey, defaultValue, errorCode, Reason.Error, string.Empty, + ex.Message); + await hookRunner.TriggerErrorHooksAsync(ex, options?.HookHints, cancellationToken) + .ConfigureAwait(false); } - - private async Task TriggerErrorHooks(IReadOnlyList hooks, HookContext context, Exception exception, - FlagEvaluationOptions options) + finally { - foreach (var hook in hooks) - { - try - { - await hook.Error(context, exception, options?.HookHints); - } - catch (Exception e) - { - this._logger.LogError(e, "Error while executing Error hook {0}", hook.GetType().Name); - } - } + evaluation ??= new FlagEvaluationDetails(flagKey, defaultValue, ErrorType.General, Reason.Error, + string.Empty, + "Evaluation failed to return a result."); + await hookRunner.TriggerFinallyHooksAsync(evaluation, options?.HookHints, cancellationToken) + .ConfigureAwait(false); } - private async Task TriggerFinallyHooks(IReadOnlyList hooks, HookContext context, - FlagEvaluationOptions options) + return evaluation; + } + + /// + /// Use this method to track user interactions and the application state. + /// + /// The name associated with this tracking event + /// The evaluation context used in the evaluation of the flag (optional) + /// Data pertinent to the tracking event (Optional) + /// When trackingEventName is null or empty + public void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default) + { + if (string.IsNullOrWhiteSpace(trackingEventName)) { - foreach (var hook in hooks) - { - try - { - await hook.Finally(context, options?.HookHints); - } - catch (Exception e) - { - this._logger.LogError(e, "Error while executing Finally hook {0}", hook.GetType().Name); - } - } + throw new ArgumentException("Tracking event cannot be null or empty.", nameof(trackingEventName)); } + + var globalContext = Api.Instance.GetContext(); + var clientContext = this.GetContext(); + + var evaluationContextBuilder = EvaluationContext.Builder() + .Merge(globalContext) + .Merge(clientContext); + if (evaluationContext != null) evaluationContextBuilder.Merge(evaluationContext); + + this._providerAccessor.Invoke().Track(trackingEventName, evaluationContextBuilder.Build(), trackingEventDetails); } + + [LoggerMessage(101, LogLevel.Error, "Error while evaluating flag {FlagKey}")] + partial void FlagEvaluationError(string flagKey, Exception exception); + + [LoggerMessage(100, LogLevel.Debug, "Hook {HookName} returned null, nothing to merge back into context")] + partial void HookReturnedNull(string hookName); + + [LoggerMessage(102, LogLevel.Error, "Error while evaluating flag {FlagKey}: {ErrorType}")] + partial void FlagEvaluationErrorWithDescription(string flagKey, string errorType, Exception exception); } diff --git a/src/OpenFeature/ProviderRepository.cs b/src/OpenFeature/ProviderRepository.cs new file mode 100644 index 00000000..54e797db --- /dev/null +++ b/src/OpenFeature/ProviderRepository.cs @@ -0,0 +1,291 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using OpenFeature.Constant; +using OpenFeature.Model; + + +namespace OpenFeature; + +/// +/// This class manages the collection of providers, both default and named, contained by the API. +/// +internal sealed partial class ProviderRepository : IAsyncDisposable +{ + private ILogger _logger = NullLogger.Instance; + + private FeatureProvider _defaultProvider = new NoOpFeatureProvider(); + + private readonly ConcurrentDictionary _featureProviders = + new ConcurrentDictionary(); + + /// The reader/writer locks is not disposed because the singleton instance should never be disposed. + /// + /// Mutations of the _defaultProvider or _featureProviders are done within this lock even though + /// _featureProvider is a concurrent collection. This is for a couple of reasons, the first is that + /// a provider should only be shutdown if it is not in use, and it could be in use as either a named or + /// default provider. + /// + /// The second is that a concurrent collection doesn't provide any ordering, so we could check a provider + /// as it was being added or removed such as two concurrent calls to SetProvider replacing multiple instances + /// of that provider under different names. + private readonly ReaderWriterLockSlim _providersLock = new ReaderWriterLockSlim(); + + public async ValueTask DisposeAsync() + { + using (this._providersLock) + { + await this.ShutdownAsync().ConfigureAwait(false); + } + } + + internal void SetLogger(ILogger logger) => this._logger = logger; + + /// + /// Set the default provider + /// + /// the provider to set as the default, passing null has no effect + /// the context to initialize the provider with + /// + /// called after the provider has initialized successfully, only called if the provider needed initialization + /// + /// + /// called if an error happens during the initialization of the provider, only called if the provider needed + /// initialization + /// + public async Task SetProviderAsync( + FeatureProvider? featureProvider, + EvaluationContext context, + Func? afterInitSuccess = null, + Func? afterInitError = null) + { + // Cannot unset the feature provider. + if (featureProvider == null) + { + return; + } + + this._providersLock.EnterWriteLock(); + // Default provider is swapped synchronously, initialization and shutdown may happen asynchronously. + try + { + // Setting the provider to the same provider should not have an effect. + if (ReferenceEquals(featureProvider, this._defaultProvider)) + { + return; + } + + var oldProvider = this._defaultProvider; + this._defaultProvider = featureProvider; + // We want to allow shutdown to happen concurrently with initialization, and the caller to not + // wait for it. + _ = this.ShutdownIfUnusedAsync(oldProvider); + } + finally + { + this._providersLock.ExitWriteLock(); + } + + await InitProviderAsync(this._defaultProvider, context, afterInitSuccess, afterInitError) + .ConfigureAwait(false); + } + + private static async Task InitProviderAsync( + FeatureProvider? newProvider, + EvaluationContext context, + Func? afterInitialization, + Func? afterError) + { + if (newProvider == null) + { + return; + } + if (newProvider.Status == ProviderStatus.NotReady) + { + try + { + await newProvider.InitializeAsync(context).ConfigureAwait(false); + if (afterInitialization != null) + { + await afterInitialization.Invoke(newProvider).ConfigureAwait(false); + } + } + catch (Exception ex) + { + if (afterError != null) + { + await afterError.Invoke(newProvider, ex).ConfigureAwait(false); + } + } + } + } + + /// + /// Set a named provider + /// + /// an identifier which logically binds clients with providers + /// the provider to set as the default, passing null has no effect + /// the context to initialize the provider with + /// + /// called after the provider has initialized successfully, only called if the provider needed initialization + /// + /// + /// called if an error happens during the initialization of the provider, only called if the provider needed + /// initialization + /// + /// The to cancel any async side effects. + public async Task SetProviderAsync(string? domain, + FeatureProvider? featureProvider, + EvaluationContext context, + Func? afterInitSuccess = null, + Func? afterInitError = null, + CancellationToken cancellationToken = default) + { + // Cannot set a provider for a null domain. + if (domain == null) + { + return; + } + + this._providersLock.EnterWriteLock(); + + try + { + this._featureProviders.TryGetValue(domain, out var oldProvider); + if (featureProvider != null) + { + this._featureProviders.AddOrUpdate(domain, featureProvider, + (key, current) => featureProvider); + } + else + { + // If names of clients are programmatic, then setting the provider to null could result + // in unbounded growth of the collection. + this._featureProviders.TryRemove(domain, out _); + } + + // We want to allow shutdown to happen concurrently with initialization, and the caller to not + // wait for it. + _ = this.ShutdownIfUnusedAsync(oldProvider); + } + finally + { + this._providersLock.ExitWriteLock(); + } + + await InitProviderAsync(featureProvider, context, afterInitSuccess, afterInitError).ConfigureAwait(false); + } + + /// + /// Shutdown the feature provider if it is unused. This must be called within a write lock of the _providersLock. + /// + private async Task ShutdownIfUnusedAsync( + FeatureProvider? targetProvider) + { + if (ReferenceEquals(this._defaultProvider, targetProvider)) + { + return; + } + + if (targetProvider != null && this._featureProviders.Values.Contains(targetProvider)) + { + return; + } + + await this.SafeShutdownProviderAsync(targetProvider).ConfigureAwait(false); + } + + /// + /// + /// Shut down the provider and capture any exceptions thrown. + /// + /// + /// The provider is set either to a name or default before the old provider it shut down, so + /// it would not be meaningful to emit an error. + /// + /// + private async Task SafeShutdownProviderAsync(FeatureProvider? targetProvider) + { + if (targetProvider == null) + { + return; + } + + try + { + await targetProvider.ShutdownAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + this.ErrorShuttingDownProvider(targetProvider.GetMetadata()?.Name, ex); + } + } + + public FeatureProvider GetProvider() + { + this._providersLock.EnterReadLock(); + try + { + return this._defaultProvider; + } + finally + { + this._providersLock.ExitReadLock(); + } + } + + public FeatureProvider GetProvider(string? domain) + { +#if NET6_0_OR_GREATER + if (string.IsNullOrEmpty(domain)) + { + return this.GetProvider(); + } +#else + // This is a workaround for the issue in .NET Framework where string.IsNullOrEmpty is not nullable compatible. + if (domain == null || string.IsNullOrEmpty(domain)) + { + return this.GetProvider(); + } +#endif + + return this._featureProviders.TryGetValue(domain, out var featureProvider) + ? featureProvider + : this.GetProvider(); + } + + public async Task ShutdownAsync(Action? afterError = null, CancellationToken cancellationToken = default) + { + var providers = new HashSet(); + this._providersLock.EnterWriteLock(); + try + { + providers.Add(this._defaultProvider); + foreach (var featureProvidersValue in this._featureProviders.Values) + { + providers.Add(featureProvidersValue); + } + + // Set a default provider so the Api is ready to be used again. + this._defaultProvider = new NoOpFeatureProvider(); + this._featureProviders.Clear(); + } + finally + { + this._providersLock.ExitWriteLock(); + } + + foreach (var targetProvider in providers) + { + // We don't need to take any actions after shutdown. + await this.SafeShutdownProviderAsync(targetProvider).ConfigureAwait(false); + } + } + + [LoggerMessage(EventId = 105, Level = LogLevel.Error, Message = "Error shutting down provider: {TargetProviderName}`")] + partial void ErrorShuttingDownProvider(string? targetProviderName, Exception exception); +} diff --git a/src/OpenFeature/Providers/Memory/Flag.cs b/src/OpenFeature/Providers/Memory/Flag.cs new file mode 100644 index 00000000..fd8cf19f --- /dev/null +++ b/src/OpenFeature/Providers/Memory/Flag.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using OpenFeature.Constant; +using OpenFeature.Error; +using OpenFeature.Model; + +namespace OpenFeature.Providers.Memory; + +/// +/// Flag representation for the in-memory provider. +/// +public interface Flag; + +/// +/// Flag representation for the in-memory provider. +/// +public sealed class Flag : Flag +{ + private readonly Dictionary _variants; + private readonly string _defaultVariant; + private readonly Func? _contextEvaluator; + private readonly ImmutableMetadata? _flagMetadata; + + /// + /// Flag representation for the in-memory provider. + /// + /// dictionary of variants and their corresponding values + /// default variant (should match 1 key in variants dictionary) + /// optional context-sensitive evaluation function + /// optional metadata for the flag + public Flag(Dictionary variants, string defaultVariant, Func? contextEvaluator = null, ImmutableMetadata? flagMetadata = null) + { + this._variants = variants; + this._defaultVariant = defaultVariant; + this._contextEvaluator = contextEvaluator; + this._flagMetadata = flagMetadata; + } + + internal ResolutionDetails Evaluate(string flagKey, T _, EvaluationContext? evaluationContext) + { + T? value; + if (this._contextEvaluator == null) + { + if (this._variants.TryGetValue(this._defaultVariant, out value)) + { + return new ResolutionDetails( + flagKey, + value, + variant: this._defaultVariant, + reason: Reason.Static, + flagMetadata: this._flagMetadata + ); + } + else + { + throw new GeneralException($"variant {this._defaultVariant} not found"); + } + } + else + { + var variant = this._contextEvaluator.Invoke(evaluationContext ?? EvaluationContext.Empty); + if (!this._variants.TryGetValue(variant, out value)) + { + throw new GeneralException($"variant {variant} not found"); + } + else + { + return new ResolutionDetails( + flagKey, + value, + variant: variant, + reason: Reason.TargetingMatch, + flagMetadata: this._flagMetadata + ); + } + } + } +} diff --git a/src/OpenFeature/Providers/Memory/InMemoryProvider.cs b/src/OpenFeature/Providers/Memory/InMemoryProvider.cs new file mode 100644 index 00000000..fce7afe1 --- /dev/null +++ b/src/OpenFeature/Providers/Memory/InMemoryProvider.cs @@ -0,0 +1,116 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using OpenFeature.Constant; +using OpenFeature.Model; + +namespace OpenFeature.Providers.Memory; + +/// +/// The in memory provider. +/// Useful for testing and demonstration purposes. +/// +/// In Memory Provider specification +public class InMemoryProvider : FeatureProvider +{ + private readonly Metadata _metadata = new Metadata("InMemory"); + + private Dictionary _flags; + + /// + public override Metadata GetMetadata() + { + return this._metadata; + } + + /// + /// Construct a new InMemoryProvider. + /// + /// dictionary of Flags + public InMemoryProvider(IDictionary? flags = null) + { + if (flags == null) + { + this._flags = new Dictionary(); + } + else + { + this._flags = new Dictionary(flags); // shallow copy + } + } + + /// + /// Update provider flag configuration, replacing all flags. + /// + /// the flags to use instead of the previous flags. + public async Task UpdateFlagsAsync(IDictionary? flags = null) + { + var changed = this._flags.Keys.ToList(); + if (flags == null) + { + this._flags = new Dictionary(); + } + else + { + this._flags = new Dictionary(flags); // shallow copy + } + changed.AddRange(this._flags.Keys.ToList()); + var @event = new ProviderEventPayload + { + Type = ProviderEventTypes.ProviderConfigurationChanged, + ProviderName = this._metadata.Name, + FlagsChanged = changed, // emit all + Message = "flags changed", + }; + + await this.EventChannel.Writer.WriteAsync(@event).ConfigureAwait(false); + } + + /// + public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(this.Resolve(flagKey, defaultValue, context)); + } + + /// + public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(this.Resolve(flagKey, defaultValue, context)); + } + + /// + public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(this.Resolve(flagKey, defaultValue, context)); + } + + /// + public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(this.Resolve(flagKey, defaultValue, context)); + } + + /// + public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(this.Resolve(flagKey, defaultValue, context)); + } + + private ResolutionDetails Resolve(string flagKey, T defaultValue, EvaluationContext? context) + { + if (!this._flags.TryGetValue(flagKey, out var flag)) + { + return new ResolutionDetails(flagKey, defaultValue, ErrorType.FlagNotFound, Reason.Error); + } + + // This check returns False if a floating point flag is evaluated as an integer flag, and vice-versa. + // In a production provider, such behavior is probably not desirable; consider supporting conversion. + if (flag is Flag value) + { + return value.Evaluate(flagKey, defaultValue, context); + } + + return new ResolutionDetails(flagKey, defaultValue, ErrorType.TypeMismatch, Reason.Error); + } +} diff --git a/src/OpenFeature/SharedHookContext.cs b/src/OpenFeature/SharedHookContext.cs new file mode 100644 index 00000000..c364e40c --- /dev/null +++ b/src/OpenFeature/SharedHookContext.cs @@ -0,0 +1,59 @@ +using System; +using OpenFeature.Constant; +using OpenFeature.Model; + +namespace OpenFeature; + +/// +/// Component of the hook context which shared between all hook instances +/// +/// Feature flag key +/// Default value +/// Flag value type +/// Client metadata +/// Provider metadata +/// Flag value type +internal class SharedHookContext( + string? flagKey, + T defaultValue, + FlagValueType flagValueType, + ClientMetadata? clientMetadata, + Metadata? providerMetadata) +{ + /// + /// Feature flag being evaluated + /// + public string FlagKey { get; } = flagKey ?? throw new ArgumentNullException(nameof(flagKey)); + + /// + /// Default value if flag fails to be evaluated + /// + public T DefaultValue { get; } = defaultValue; + + /// + /// The value type of the flag + /// + public FlagValueType FlagValueType { get; } = flagValueType; + + /// + /// Client metadata + /// + public ClientMetadata ClientMetadata { get; } = + clientMetadata ?? throw new ArgumentNullException(nameof(clientMetadata)); + + /// + /// Provider metadata + /// + public Metadata ProviderMetadata { get; } = + providerMetadata ?? throw new ArgumentNullException(nameof(providerMetadata)); + + /// + /// Create a hook context from this shared context. + /// + /// Evaluation context + /// A hook context + public HookContext ToHookContext(EvaluationContext? evaluationContext) + { + return new HookContext(this, evaluationContext, new HookData()); + } +} diff --git a/test/OpenFeature.Benchmarks/OpenFeature.Benchmarks.csproj b/test/OpenFeature.Benchmarks/OpenFeature.Benchmarks.csproj new file mode 100644 index 00000000..c0dc300a --- /dev/null +++ b/test/OpenFeature.Benchmarks/OpenFeature.Benchmarks.csproj @@ -0,0 +1,18 @@ + + + + net8.0;net9.0 + OpenFeature.Benchmark + Exe + + + + + + + + + + + + diff --git a/test/OpenFeature.Benchmarks/OpenFeatureClientBenchmarks.cs b/test/OpenFeature.Benchmarks/OpenFeatureClientBenchmarks.cs new file mode 100644 index 00000000..d4c770eb --- /dev/null +++ b/test/OpenFeature.Benchmarks/OpenFeatureClientBenchmarks.cs @@ -0,0 +1,103 @@ + +using System.Collections.Immutable; +using System.Threading.Tasks; +using AutoFixture; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using OpenFeature.Model; + +namespace OpenFeature.Benchmark; + +[MemoryDiagnoser] +[SimpleJob(RuntimeMoniker.Net80, baseline: true)] +[JsonExporterAttribute.Full] +[JsonExporterAttribute.FullCompressed] +public class OpenFeatureClientBenchmarks +{ + private readonly string _domain; + private readonly string _clientVersion; + private readonly string _flagName; + private readonly bool _defaultBoolValue; + private readonly string _defaultStringValue; + private readonly int _defaultIntegerValue; + private readonly double _defaultDoubleValue; + private readonly Value _defaultStructureValue; + private readonly FlagEvaluationOptions _emptyFlagOptions; + private readonly FeatureClient _client; + + public OpenFeatureClientBenchmarks() + { + var fixture = new Fixture(); + this._domain = fixture.Create(); + this._clientVersion = fixture.Create(); + this._flagName = fixture.Create(); + this._defaultBoolValue = fixture.Create(); + this._defaultStringValue = fixture.Create(); + this._defaultIntegerValue = fixture.Create(); + this._defaultDoubleValue = fixture.Create(); + this._defaultStructureValue = fixture.Create(); + this._emptyFlagOptions = new FlagEvaluationOptions(ImmutableList.Empty, ImmutableDictionary.Empty); + + this._client = Api.Instance.GetClient(this._domain, this._clientVersion); + } + + [Benchmark] + public async Task OpenFeatureClient_GetBooleanValue_WithoutEvaluationContext_WithoutFlagEvaluationOptions() => + await this._client.GetBooleanValueAsync(this._flagName, this._defaultBoolValue); + + [Benchmark] + public async Task OpenFeatureClient_GetBooleanValue_WithEmptyEvaluationContext_WithoutFlagEvaluationOptions() => + await this._client.GetBooleanValueAsync(this._flagName, this._defaultBoolValue, EvaluationContext.Empty); + + [Benchmark] + public async Task OpenFeatureClient_GetBooleanValue_WithEmptyEvaluationContext_WithEmptyFlagEvaluationOptions() => + await this._client.GetBooleanValueAsync(this._flagName, this._defaultBoolValue, EvaluationContext.Empty, this._emptyFlagOptions); + + [Benchmark] + public async Task OpenFeatureClient_GetStringValue_WithoutEvaluationContext_WithoutFlagEvaluationOptions() => + await this._client.GetStringValueAsync(this._flagName, this._defaultStringValue); + + [Benchmark] + public async Task OpenFeatureClient_GetStringValue_WithEmptyEvaluationContext_WithoutFlagEvaluationOptions() => + await this._client.GetStringValueAsync(this._flagName, this._defaultStringValue, EvaluationContext.Empty); + + [Benchmark] + public async Task OpenFeatureClient_GetStringValue_WithoutEvaluationContext_WithEmptyFlagEvaluationOptions() => + await this._client.GetStringValueAsync(this._flagName, this._defaultStringValue, EvaluationContext.Empty, this._emptyFlagOptions); + + [Benchmark] + public async Task OpenFeatureClient_GetIntegerValue_WithoutEvaluationContext_WithoutFlagEvaluationOptions() => + await this._client.GetIntegerValueAsync(this._flagName, this._defaultIntegerValue); + + [Benchmark] + public async Task OpenFeatureClient_GetIntegerValue_WithEmptyEvaluationContext_WithoutFlagEvaluationOptions() => + await this._client.GetIntegerValueAsync(this._flagName, this._defaultIntegerValue, EvaluationContext.Empty); + + [Benchmark] + public async Task OpenFeatureClient_GetIntegerValue_WithEmptyEvaluationContext_WithEmptyFlagEvaluationOptions() => + await this._client.GetIntegerValueAsync(this._flagName, this._defaultIntegerValue, EvaluationContext.Empty, this._emptyFlagOptions); + + [Benchmark] + public async Task OpenFeatureClient_GetDoubleValue_WithoutEvaluationContext_WithoutFlagEvaluationOptions() => + await this._client.GetDoubleValueAsync(this._flagName, this._defaultDoubleValue); + + [Benchmark] + public async Task OpenFeatureClient_GetDoubleValue_WithEmptyEvaluationContext_WithoutFlagEvaluationOptions() => + await this._client.GetDoubleValueAsync(this._flagName, this._defaultDoubleValue, EvaluationContext.Empty); + + [Benchmark] + public async Task OpenFeatureClient_GetDoubleValue_WithEmptyEvaluationContext_WithEmptyFlagEvaluationOptions() => + await this._client.GetDoubleValueAsync(this._flagName, this._defaultDoubleValue, EvaluationContext.Empty, this._emptyFlagOptions); + + [Benchmark] + public async Task OpenFeatureClient_GetObjectValue_WithoutEvaluationContext_WithoutFlagEvaluationOptions() => + await this._client.GetObjectValueAsync(this._flagName, this._defaultStructureValue); + + [Benchmark] + public async Task OpenFeatureClient_GetObjectValue_WithEmptyEvaluationContext_WithoutFlagEvaluationOptions() => + await this._client.GetObjectValueAsync(this._flagName, this._defaultStructureValue, EvaluationContext.Empty); + + [Benchmark] + public async Task OpenFeatureClient_GetObjectValue_WithEmptyEvaluationContext_WithEmptyFlagEvaluationOptions() => + await this._client.GetObjectValueAsync(this._flagName, this._defaultStructureValue, EvaluationContext.Empty, this._emptyFlagOptions); +} diff --git a/test/OpenFeature.Benchmarks/Program.cs b/test/OpenFeature.Benchmarks/Program.cs new file mode 100644 index 00000000..00be344a --- /dev/null +++ b/test/OpenFeature.Benchmarks/Program.cs @@ -0,0 +1,11 @@ +using BenchmarkDotNet.Running; + +namespace OpenFeature.Benchmark; + +internal class Program +{ + static void Main(string[] args) + { + BenchmarkRunner.Run(); + } +} diff --git a/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs b/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs new file mode 100644 index 00000000..47cc7df5 --- /dev/null +++ b/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs @@ -0,0 +1,84 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging.Abstractions; +using OpenFeature.DependencyInjection.Internal; +using Xunit; + +namespace OpenFeature.DependencyInjection.Tests; + +public class FeatureLifecycleManagerTests +{ + private readonly IServiceCollection _serviceCollection; + + public FeatureLifecycleManagerTests() + { + Api.Instance.SetContext(null); + Api.Instance.ClearHooks(); + + _serviceCollection = new ServiceCollection() + .Configure(options => + { + options.AddDefaultProviderName(); + }); + } + + [Fact] + public async Task EnsureInitializedAsync_ShouldLogAndSetProvider_WhenProviderExists() + { + // Arrange + var featureProvider = new NoOpFeatureProvider(); + _serviceCollection.AddSingleton(featureProvider); + + var serviceProvider = _serviceCollection.BuildServiceProvider(); + var sut = new FeatureLifecycleManager(Api.Instance, serviceProvider, NullLogger.Instance); + + // Act + await sut.EnsureInitializedAsync().ConfigureAwait(true); + + // Assert + Assert.Equal(featureProvider, Api.Instance.GetProvider()); + } + + [Fact] + public async Task EnsureInitializedAsync_ShouldThrowException_WhenProviderDoesNotExist() + { + // Arrange + _serviceCollection.RemoveAll(); + + var serviceProvider = _serviceCollection.BuildServiceProvider(); + var sut = new FeatureLifecycleManager(Api.Instance, serviceProvider, NullLogger.Instance); + + // Act + var act = () => sut.EnsureInitializedAsync().AsTask(); + + // Assert + var exception = await Assert.ThrowsAsync(act).ConfigureAwait(true); + Assert.NotNull(exception); + Assert.False(string.IsNullOrWhiteSpace(exception.Message)); + } + + [Fact] + public async Task EnsureInitializedAsync_ShouldSetHook_WhenHooksAreRegistered() + { + // Arrange + var featureProvider = new NoOpFeatureProvider(); + var hook = new NoOpHook(); + + _serviceCollection.AddSingleton(featureProvider) + .AddKeyedSingleton("NoOpHook", (_, key) => hook) + .Configure(options => + { + options.AddHookName("NoOpHook"); + }); + + var serviceProvider = _serviceCollection.BuildServiceProvider(); + var sut = new FeatureLifecycleManager(Api.Instance, serviceProvider, NullLogger.Instance); + + // Act + await sut.EnsureInitializedAsync().ConfigureAwait(true); + + // Assert + var actual = Api.Instance.GetHooks().FirstOrDefault(); + Assert.Equal(hook, actual); + } +} diff --git a/test/OpenFeature.DependencyInjection.Tests/NoOpFeatureProvider.cs b/test/OpenFeature.DependencyInjection.Tests/NoOpFeatureProvider.cs new file mode 100644 index 00000000..ac3e5209 --- /dev/null +++ b/test/OpenFeature.DependencyInjection.Tests/NoOpFeatureProvider.cs @@ -0,0 +1,52 @@ +using OpenFeature.Model; + +namespace OpenFeature.DependencyInjection.Tests; + +// This class replicates the NoOpFeatureProvider implementation from src/OpenFeature/NoOpFeatureProvider.cs. +// It is used here to facilitate unit testing without relying on the internal NoOpFeatureProvider class. +// If the InternalsVisibleTo attribute is added to the OpenFeature project, +// this class can be removed and the original NoOpFeatureProvider can be directly accessed for testing. +internal sealed class NoOpFeatureProvider : FeatureProvider +{ + private readonly Metadata _metadata = new Metadata(NoOpProvider.NoOpProviderName); + + public override Metadata GetMetadata() + { + return this._metadata; + } + + public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + private static ResolutionDetails NoOpResponse(string flagKey, T defaultValue) + { + return new ResolutionDetails( + flagKey, + defaultValue, + reason: NoOpProvider.ReasonNoOp, + variant: NoOpProvider.Variant + ); + } +} diff --git a/test/OpenFeature.DependencyInjection.Tests/NoOpHook.cs b/test/OpenFeature.DependencyInjection.Tests/NoOpHook.cs new file mode 100644 index 00000000..cee6ef1d --- /dev/null +++ b/test/OpenFeature.DependencyInjection.Tests/NoOpHook.cs @@ -0,0 +1,26 @@ +using OpenFeature.Model; + +namespace OpenFeature.DependencyInjection.Tests; + +internal class NoOpHook : Hook +{ + public override ValueTask BeforeAsync(HookContext context, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + return base.BeforeAsync(context, hints, cancellationToken); + } + + public override ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + return base.AfterAsync(context, details, hints, cancellationToken); + } + + public override ValueTask FinallyAsync(HookContext context, FlagEvaluationDetails evaluationDetails, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + return base.FinallyAsync(context, evaluationDetails, hints, cancellationToken); + } + + public override ValueTask ErrorAsync(HookContext context, Exception error, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + return base.ErrorAsync(context, error, hints, cancellationToken); + } +} diff --git a/test/OpenFeature.DependencyInjection.Tests/NoOpProvider.cs b/test/OpenFeature.DependencyInjection.Tests/NoOpProvider.cs new file mode 100644 index 00000000..7bf20bca --- /dev/null +++ b/test/OpenFeature.DependencyInjection.Tests/NoOpProvider.cs @@ -0,0 +1,8 @@ +namespace OpenFeature.DependencyInjection.Tests; + +internal static class NoOpProvider +{ + public const string NoOpProviderName = "No-op Provider"; + public const string ReasonNoOp = "No-op"; + public const string Variant = "No-op"; +} diff --git a/test/OpenFeature.DependencyInjection.Tests/OpenFeature.DependencyInjection.Tests.csproj b/test/OpenFeature.DependencyInjection.Tests/OpenFeature.DependencyInjection.Tests.csproj new file mode 100644 index 00000000..4d714afe --- /dev/null +++ b/test/OpenFeature.DependencyInjection.Tests/OpenFeature.DependencyInjection.Tests.csproj @@ -0,0 +1,36 @@ + + + + net8.0;net9.0 + enable + enable + + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs new file mode 100644 index 00000000..07597703 --- /dev/null +++ b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs @@ -0,0 +1,304 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenFeature.Model; +using Xunit; + +namespace OpenFeature.DependencyInjection.Tests; + +public partial class OpenFeatureBuilderExtensionsTests +{ + private readonly IServiceCollection _services; + private readonly OpenFeatureBuilder _systemUnderTest; + + public OpenFeatureBuilderExtensionsTests() + { + _services = new ServiceCollection(); + _systemUnderTest = new OpenFeatureBuilder(_services); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddContext_Delegate_ShouldAddServiceToCollection(bool useServiceProviderDelegate) + { + // Act + var featureBuilder = useServiceProviderDelegate ? + _systemUnderTest.AddContext(_ => { }) : + _systemUnderTest.AddContext((_, _) => { }); + + // Assert + Assert.Equal(_systemUnderTest, featureBuilder); + Assert.True(_systemUnderTest.IsContextConfigured, "The context should be configured."); + Assert.Single(_services, serviceDescriptor => + serviceDescriptor.ServiceType == typeof(EvaluationContext) && + serviceDescriptor.Lifetime == ServiceLifetime.Transient); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddContext_Delegate_ShouldCorrectlyHandles(bool useServiceProviderDelegate) + { + // Arrange + bool delegateCalled = false; + + _ = useServiceProviderDelegate ? + _systemUnderTest.AddContext(_ => delegateCalled = true) : + _systemUnderTest.AddContext((_, _) => delegateCalled = true); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var context = serviceProvider.GetService(); + + // Assert + Assert.True(_systemUnderTest.IsContextConfigured, "The context should be configured."); + Assert.NotNull(context); + Assert.True(delegateCalled, "The delegate should be invoked."); + } + +#if NET8_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.Experimental(Diagnostics.FeatureCodes.NewDi)] +#endif + [Theory] + [InlineData(1, true, 0)] + [InlineData(2, false, 1)] + [InlineData(3, true, 0)] + [InlineData(4, false, 1)] + public void AddProvider_ShouldAddProviderToCollection(int providerRegistrationType, bool expectsDefaultProvider, int expectsDomainBoundProvider) + { + // Act + var featureBuilder = providerRegistrationType switch + { + 1 => _systemUnderTest.AddProvider(_ => new NoOpFeatureProvider()), + 2 => _systemUnderTest.AddProvider("test", (_, _) => new NoOpFeatureProvider()), + 3 => _systemUnderTest.AddProvider(_ => new NoOpFeatureProvider(), o => { }), + 4 => _systemUnderTest.AddProvider("test", (_, _) => new NoOpFeatureProvider(), o => { }), + _ => throw new InvalidOperationException("Invalid mode.") + }; + + // Assert + Assert.False(_systemUnderTest.IsContextConfigured, "The context should not be configured."); + Assert.Equal(expectsDefaultProvider, _systemUnderTest.HasDefaultProvider); + Assert.False(_systemUnderTest.IsPolicyConfigured, "The policy should not be configured."); + Assert.Equal(expectsDomainBoundProvider, _systemUnderTest.DomainBoundProviderRegistrationCount); + Assert.Equal(_systemUnderTest, featureBuilder); + Assert.Single(_services, serviceDescriptor => + serviceDescriptor.ServiceType == typeof(FeatureProvider) && + serviceDescriptor.Lifetime == ServiceLifetime.Transient); + } + + class TestOptions : OpenFeatureOptions { } + +#if NET8_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.Experimental(Diagnostics.FeatureCodes.NewDi)] +#endif + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void AddProvider_ShouldResolveCorrectProvider(int providerRegistrationType) + { + // Arrange + _ = providerRegistrationType switch + { + 1 => _systemUnderTest.AddProvider(_ => new NoOpFeatureProvider()), + 2 => _systemUnderTest.AddProvider("test", (_, _) => new NoOpFeatureProvider()), + 3 => _systemUnderTest.AddProvider(_ => new NoOpFeatureProvider(), o => { }), + 4 => _systemUnderTest.AddProvider("test", (_, _) => new NoOpFeatureProvider(), o => { }), + _ => throw new InvalidOperationException("Invalid mode.") + }; + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var provider = providerRegistrationType switch + { + 1 or 3 => serviceProvider.GetService(), + 2 or 4 => serviceProvider.GetKeyedService("test"), + _ => throw new InvalidOperationException("Invalid mode.") + }; + + // Assert + Assert.NotNull(provider); + Assert.IsType(provider); + } + + [Theory] + [InlineData(1, true, 1)] + [InlineData(2, true, 1)] + [InlineData(3, false, 2)] + [InlineData(4, true, 1)] + [InlineData(5, true, 1)] + [InlineData(6, false, 2)] + [InlineData(7, true, 2)] + [InlineData(8, true, 2)] + public void AddProvider_VerifiesDefaultAndDomainBoundProvidersBasedOnConfiguration(int providerRegistrationType, bool expectsDefaultProvider, int expectsDomainBoundProvider) + { + // Act + var featureBuilder = providerRegistrationType switch + { + 1 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider()) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()), + 2 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider()) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()), + 3 => _systemUnderTest + .AddProvider("test1", (_, _) => new NoOpFeatureProvider()) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider()), + 4 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider(), o => { }) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()), + 5 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider(), o => { }) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()), + 6 => _systemUnderTest + .AddProvider("test1", (_, _) => new NoOpFeatureProvider(), o => { }) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider()), + 7 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider()) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider()), + 8 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider(), o => { }) + .AddProvider("test", (_, _) => new NoOpFeatureProvider(), o => { }) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider(), o => { }), + _ => throw new InvalidOperationException("Invalid mode.") + }; + + // Assert + Assert.False(_systemUnderTest.IsContextConfigured, "The context should not be configured."); + Assert.Equal(expectsDefaultProvider, _systemUnderTest.HasDefaultProvider); + Assert.False(_systemUnderTest.IsPolicyConfigured, "The policy should not be configured."); + Assert.Equal(expectsDomainBoundProvider, _systemUnderTest.DomainBoundProviderRegistrationCount); + Assert.Equal(_systemUnderTest, featureBuilder); + } + + [Theory] + [InlineData(1, null)] + [InlineData(2, "test")] + [InlineData(3, "test2")] + [InlineData(4, "test")] + [InlineData(5, null)] + [InlineData(6, "test1")] + [InlineData(7, "test2")] + [InlineData(8, null)] + public void AddProvider_ConfiguresPolicyNameAcrossMultipleProviderSetups(int providerRegistrationType, string? policyName) + { + // Arrange + var featureBuilder = providerRegistrationType switch + { + 1 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider()) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + 2 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider()) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + 3 => _systemUnderTest + .AddProvider("test1", (_, _) => new NoOpFeatureProvider()) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider()) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + 4 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider(), o => { }) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + 5 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider(), o => { }) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + 6 => _systemUnderTest + .AddProvider("test1", (_, _) => new NoOpFeatureProvider(), o => { }) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider()) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + 7 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider()) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider()) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + 8 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider(), o => { }) + .AddProvider("test", (_, _) => new NoOpFeatureProvider(), o => { }) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider(), o => { }) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + _ => throw new InvalidOperationException("Invalid mode.") + }; + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var policy = serviceProvider.GetRequiredService>().Value; + var name = policy.DefaultNameSelector(serviceProvider); + var provider = name == null ? + serviceProvider.GetService() : + serviceProvider.GetRequiredKeyedService(name); + + // Assert + Assert.True(featureBuilder.IsPolicyConfigured, "The policy should be configured."); + Assert.NotNull(provider); + Assert.IsType(provider); + } + + [Fact] + public void AddHook_AddsHookAsKeyedService() + { + // Arrange + _systemUnderTest.AddHook(); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var hook = serviceProvider.GetKeyedService("NoOpHook"); + + // Assert + Assert.NotNull(hook); + } + + [Fact] + public void AddHook_AddsHookNameToOpenFeatureOptions() + { + // Arrange + _systemUnderTest.AddHook(sp => new NoOpHook()); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>(); + + // Assert + Assert.Contains(options.Value.HookNames, t => t == "NoOpHook"); + } + + [Fact] + public void AddHook_WithSpecifiedNameToOpenFeatureOptions() + { + // Arrange + _systemUnderTest.AddHook("my-custom-name"); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var hook = serviceProvider.GetKeyedService("my-custom-name"); + + // Assert + Assert.NotNull(hook); + } + + [Fact] + public void AddHook_WithSpecifiedNameAndImplementationFactory_AsKeyedService() + { + // Arrange + _systemUnderTest.AddHook("my-custom-name", (serviceProvider) => new NoOpHook()); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var hook = serviceProvider.GetKeyedService("my-custom-name"); + + // Assert + Assert.NotNull(hook); + } +} diff --git a/test/OpenFeature.DependencyInjection.Tests/OpenFeatureServiceCollectionExtensionsTests.cs b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..d3ce5c8e --- /dev/null +++ b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureServiceCollectionExtensionsTests.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Xunit; + +namespace OpenFeature.DependencyInjection.Tests; + +public class OpenFeatureServiceCollectionExtensionsTests +{ + private readonly IServiceCollection _systemUnderTest; + private readonly Action _configureAction; + + public OpenFeatureServiceCollectionExtensionsTests() + { + _systemUnderTest = new ServiceCollection(); + _configureAction = Substitute.For>(); + } + + [Fact] + public void AddOpenFeature_ShouldRegisterApiInstanceAndLifecycleManagerAsSingleton() + { + // Act + _systemUnderTest.AddOpenFeature(_configureAction); + + Assert.Single(_systemUnderTest, s => s.ServiceType == typeof(Api) && s.Lifetime == ServiceLifetime.Singleton); + Assert.Single(_systemUnderTest, s => s.ServiceType == typeof(IFeatureLifecycleManager) && s.Lifetime == ServiceLifetime.Singleton); + Assert.Single(_systemUnderTest, s => s.ServiceType == typeof(IFeatureClient) && s.Lifetime == ServiceLifetime.Scoped); + } + + [Fact] + public void AddOpenFeature_ShouldInvokeConfigureAction() + { + // Act + _systemUnderTest.AddOpenFeature(_configureAction); + + // Assert + _configureAction.Received(1).Invoke(Arg.Any()); + } +} diff --git a/test/OpenFeature.E2ETests/Features/.gitkeep b/test/OpenFeature.E2ETests/Features/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj b/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj new file mode 100644 index 00000000..0d5ed8ce --- /dev/null +++ b/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj @@ -0,0 +1,39 @@ + + + + net8.0;net9.0 + $(TargetFrameworks);net462 + OpenFeature.E2ETests + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + diff --git a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs new file mode 100644 index 00000000..6b2bfebf --- /dev/null +++ b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs @@ -0,0 +1,275 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using OpenFeature.E2ETests.Utils; +using OpenFeature.Model; +using OpenFeature.Providers.Memory; +using Reqnroll; + +namespace OpenFeature.E2ETests.Steps; + +[Binding] +public class BaseStepDefinitions +{ + protected readonly State State; + + public BaseStepDefinitions(State state) + { + this.State = state; + } + + [Given(@"a stable provider")] + public async Task GivenAStableProvider() + { + var memProvider = new InMemoryProvider(E2EFlagConfig); + await Api.Instance.SetProviderAsync(memProvider).ConfigureAwait(false); + this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); + } + + [Given(@"a Boolean-flag with key ""(.*)"" and a default value ""(.*)""")] + [Given(@"a boolean-flag with key ""(.*)"" and a default value ""(.*)""")] + public void GivenABoolean_FlagWithKeyAndADefaultValue(string key, string defaultType) + { + var flagState = new FlagState(key, defaultType, FlagType.Boolean); + this.State.Flag = flagState; + } + + [Given(@"a Float-flag with key ""(.*)"" and a default value ""(.*)""")] + [Given(@"a float-flag with key ""(.*)"" and a default value ""(.*)""")] + public void GivenAFloat_FlagWithKeyAndADefaultValue(string key, string defaultType) + { + var flagState = new FlagState(key, defaultType, FlagType.Float); + this.State.Flag = flagState; + } + + [Given(@"a Integer-flag with key ""(.*)"" and a default value ""(.*)""")] + [Given(@"a integer-flag with key ""(.*)"" and a default value ""(.*)""")] + public void GivenAnInteger_FlagWithKeyAndADefaultValue(string key, string defaultType) + { + var flagState = new FlagState(key, defaultType, FlagType.Integer); + this.State.Flag = flagState; + } + + [Given(@"a String-flag with key ""(.*)"" and a default value ""(.*)""")] + [Given(@"a string-flag with key ""(.*)"" and a default value ""(.*)""")] + public void GivenAString_FlagWithKeyAndADefaultValue(string key, string defaultType) + { + var flagState = new FlagState(key, defaultType, FlagType.String); + this.State.Flag = flagState; + } + + [Given("a stable provider with retrievable context is registered")] + public async Task GivenAStableProviderWithRetrievableContextIsRegistered() + { + this.State.ContextStoringProvider = new ContextStoringProvider(); + + await Api.Instance.SetProviderAsync(this.State.ContextStoringProvider).ConfigureAwait(false); + + Api.Instance.SetTransactionContextPropagator(new AsyncLocalTransactionContextPropagator()); + + this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); + } + + [Given(@"A context entry with key ""(.*)"" and value ""(.*)"" is added to the ""(.*)"" level")] + public void GivenAContextEntryWithKeyAndValueIsAddedToTheLevel(string key, string value, string level) + { + var context = EvaluationContext.Builder() + .Set(key, value) + .Build(); + + this.InitializeContext(level, context); + } + + [Given("A table with levels of increasing precedence")] + public void GivenATableWithLevelsOfIncreasingPrecedence(DataTable dataTable) + { + var items = dataTable.Rows.ToList(); + + var levels = items.Select(r => r.Values.First()); + + this.State.ContextPrecedenceLevels = levels.ToArray(); + } + + [Given(@"Context entries for each level from API level down to the ""(.*)"" level, with key ""(.*)"" and value ""(.*)""")] + public void GivenContextEntriesForEachLevelFromAPILevelDownToTheLevelWithKeyAndValue(string currentLevel, string key, string value) + { + if (this.State.ContextPrecedenceLevels == null) + this.State.ContextPrecedenceLevels = new string[0]; + + foreach (var level in this.State.ContextPrecedenceLevels) + { + var context = EvaluationContext.Builder() + .Set(key, value) + .Build(); + + this.InitializeContext(level, context); + } + } + + [When(@"the flag was evaluated with details")] + public async Task WhenTheFlagWasEvaluatedWithDetails() + { + var flag = this.State.Flag!; + + switch (flag.Type) + { + case FlagType.Boolean: + this.State.FlagEvaluationDetailsResult = await this.State.Client! + .GetBooleanDetailsAsync(flag.Key, bool.Parse(flag.DefaultValue)).ConfigureAwait(false); + break; + case FlagType.Float: + this.State.FlagEvaluationDetailsResult = await this.State.Client! + .GetDoubleDetailsAsync(flag.Key, double.Parse(flag.DefaultValue)).ConfigureAwait(false); + break; + case FlagType.Integer: + this.State.FlagEvaluationDetailsResult = await this.State.Client! + .GetIntegerDetailsAsync(flag.Key, int.Parse(flag.DefaultValue)).ConfigureAwait(false); + break; + case FlagType.String: + this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetStringDetailsAsync(flag.Key, flag.DefaultValue) + .ConfigureAwait(false); + break; + } + } + private void InitializeContext(string level, EvaluationContext context) + { + switch (level) + { + case "API": + { + Api.Instance.SetContext(context); + break; + } + case "Transaction": + { + Api.Instance.SetTransactionContext(context); + break; + } + case "Client": + { + if (this.State.Client != null) + { + this.State.Client.SetContext(context); + } + else + { + throw new PendingStepException("You must initialise a FeatureClient before adding some EvaluationContext"); + } + break; + } + case "Invocation": + { + this.State.InvocationEvaluationContext = context; + break; + } + case "Before Hooks": // Assumed before hooks is the same as Invocation + { + if (this.State.Client != null) + { + this.State.Client.AddHooks(new BeforeHook(context)); + } + else + { + throw new PendingStepException("You must initialise a FeatureClient before adding some EvaluationContext"); + } + + break; + } + default: + throw new PendingStepException("Context level not defined"); + } + } + + private static readonly IDictionary E2EFlagConfig = new Dictionary + { + { + "metadata-flag", new Flag( + variants: new Dictionary { { "on", true }, { "off", false } }, + defaultVariant: "on", + flagMetadata: new ImmutableMetadata(new Dictionary + { + { "string", "1.0.2" }, { "integer", 2 }, { "float", 0.1 }, { "boolean", true } + }) + ) + }, + { + "boolean-flag", new Flag( + variants: new Dictionary { { "on", true }, { "off", false } }, + defaultVariant: "on" + ) + }, + { + "string-flag", new Flag( + variants: new Dictionary() { { "greeting", "hi" }, { "parting", "bye" } }, + defaultVariant: "greeting" + ) + }, + { + "integer-flag", new Flag( + variants: new Dictionary() { { "one", 1 }, { "ten", 10 } }, + defaultVariant: "ten" + ) + }, + { + "float-flag", new Flag( + variants: new Dictionary() { { "tenth", 0.1 }, { "half", 0.5 } }, + defaultVariant: "half" + ) + }, + { + "object-flag", new Flag( + variants: new Dictionary() + { + { "empty", new Value() }, + { + "template", new Value(Structure.Builder() + .Set("showImages", true) + .Set("title", "Check out these pics!") + .Set("imagesPerPage", 100).Build() + ) + } + }, + defaultVariant: "template" + ) + }, + { + "context-aware", new Flag( + variants: new Dictionary() { { "internal", "INTERNAL" }, { "external", "EXTERNAL" } }, + defaultVariant: "external", + (context) => + { + if (context.GetValue("fn").AsString == "Sulisล‚aw" + && context.GetValue("ln").AsString == "ลšwiฤ™topeล‚k" + && context.GetValue("age").AsInteger == 29 + && context.GetValue("customer").AsBoolean == false) + { + return "internal"; + } + else return "external"; + } + ) + }, + { + "wrong-flag", new Flag( + variants: new Dictionary() { { "one", "uno" }, { "two", "dos" } }, + defaultVariant: "one" + ) + } + }; + + public class BeforeHook : Hook + { + private readonly EvaluationContext context; + + public BeforeHook(EvaluationContext context) + { + this.context = context; + } + + public override ValueTask BeforeAsync(HookContext context, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + return new ValueTask(this.context); + } + } +} diff --git a/test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs new file mode 100644 index 00000000..c9f454ac --- /dev/null +++ b/test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs @@ -0,0 +1,35 @@ +using System.Threading.Tasks; +using OpenFeature.E2ETests.Utils; +using Reqnroll; +using Xunit; + +namespace OpenFeature.E2ETests.Steps; + +[Binding] +[Scope(Feature = "Context merging precedence")] +public class ContextMergingPrecedenceStepDefinitions : BaseStepDefinitions +{ + public ContextMergingPrecedenceStepDefinitions(State state) : base(state) + { + } + + [When("Some flag was evaluated")] + public async Task WhenSomeFlagWasEvaluated() + { + this.State.Flag = new FlagState("boolean-flag", "true", FlagType.Boolean); + this.State.FlagResult = await this.State.Client!.GetBooleanValueAsync("boolean-flag", true, this.State.InvocationEvaluationContext).ConfigureAwait(false); + } + + [Then(@"The merged context contains an entry with key ""(.*)"" and value ""(.*)""")] + public void ThenTheMergedContextContainsAnEntryWithKeyAndValue(string key, string value) + { + var provider = this.State.ContextStoringProvider; + + var mergedContext = provider!.EvaluationContext!; + + Assert.NotNull(mergedContext); + + var actualValue = mergedContext.GetValue(key); + Assert.Contains(value, actualValue.AsString); + } +} diff --git a/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs new file mode 100644 index 00000000..6efcf3d4 --- /dev/null +++ b/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs @@ -0,0 +1,261 @@ +using System.Threading.Tasks; +using OpenFeature.Constant; +using OpenFeature.E2ETests.Utils; +using OpenFeature.Extension; +using OpenFeature.Model; +using Reqnroll; +using Xunit; + +namespace OpenFeature.E2ETests.Steps; + +[Binding] +[Scope(Feature = "Flag evaluation")] +public class EvaluationStepDefinitions : BaseStepDefinitions +{ + public EvaluationStepDefinitions(State state) : base(state) + { + } + + [When(@"a boolean flag with key ""(.*)"" is evaluated with default value ""(.*)""")] + public async Task Whenabooleanflagwithkeyisevaluatedwithdefaultvalue(string flagKey, bool defaultValue) + { + this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Boolean); + this.State.FlagResult = await this.State.Client!.GetBooleanValueAsync(flagKey, defaultValue).ConfigureAwait(false); + } + + [Then(@"the resolved boolean value should be ""(.*)""")] + public void Thentheresolvedbooleanvalueshouldbe(bool expectedValue) + { + var result = this.State.FlagResult as bool?; + Assert.Equal(expectedValue, result); + } + + [When(@"a string flag with key ""(.*)"" is evaluated with default value ""(.*)""")] + public async Task Whenastringflagwithkeyisevaluatedwithdefaultvalue(string flagKey, string defaultValue) + { + this.State.Flag = new FlagState(flagKey, defaultValue, FlagType.String); + this.State.FlagResult = await this.State.Client!.GetStringValueAsync(flagKey, defaultValue).ConfigureAwait(false); + } + + [Then(@"the resolved string value should be ""(.*)""")] + public void Thentheresolvedstringvalueshouldbe(string expected) + { + var result = this.State.FlagResult as string; + Assert.Equal(expected, result); + } + + [When(@"an integer flag with key ""(.*)"" is evaluated with default value (.*)")] + public async Task Whenanintegerflagwithkeyisevaluatedwithdefaultvalue(string flagKey, int defaultValue) + { + this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Integer); + this.State.FlagResult = await this.State.Client!.GetIntegerValueAsync(flagKey, defaultValue).ConfigureAwait(false); + } + + [Then(@"the resolved integer value should be (.*)")] + public void Thentheresolvedintegervalueshouldbe(int expected) + { + var result = this.State.FlagResult as int?; + Assert.Equal(expected, result); + } + + [When(@"a float flag with key ""(.*)"" is evaluated with default value (.*)")] + public async Task Whenafloatflagwithkeyisevaluatedwithdefaultvalue(string flagKey, double defaultValue) + { + this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Float); + this.State.FlagResult = await this.State.Client!.GetDoubleValueAsync(flagKey, defaultValue).ConfigureAwait(false); + } + + [Then(@"the resolved float value should be (.*)")] + public void Thentheresolvedfloatvalueshouldbe(double expected) + { + var result = this.State.FlagResult as double?; + Assert.Equal(expected, result); + } + + [When(@"an object flag with key ""(.*)"" is evaluated with a null default value")] + public async Task Whenanobjectflagwithkeyisevaluatedwithanulldefaultvalue(string flagKey) + { + this.State.Flag = new FlagState(flagKey, null!, FlagType.Object); + this.State.FlagResult = await this.State.Client!.GetObjectValueAsync(flagKey, new Value()).ConfigureAwait(false); + } + + [Then(@"the resolved object value should be contain fields ""(.*)"", ""(.*)"", and ""(.*)"", with values ""(.*)"", ""(.*)"" and (.*), respectively")] + public void Thentheresolvedobjectvalueshouldbecontainfieldsandwithvaluesandrespectively(string boolField, string stringField, string numberField, bool boolValue, string stringValue, int numberValue) + { + Value? value = this.State.FlagResult as Value; + Assert.Equal(boolValue, value?.AsStructure?[boolField].AsBoolean); + Assert.Equal(stringValue, value?.AsStructure?[stringField].AsString); + Assert.Equal(numberValue, value?.AsStructure?[numberField].AsInteger); + } + + [When(@"a boolean flag with key ""(.*)"" is evaluated with details and default value ""(.*)""")] + public async Task Whenabooleanflagwithkeyisevaluatedwithdetailsanddefaultvalue(string flagKey, bool defaultValue) + { + this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Boolean); + this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetBooleanDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); + } + + [Then(@"the resolved boolean details value should be ""(.*)"", the variant should be ""(.*)"", and the reason should be ""(.*)""")] + public void Thentheresolvedbooleandetailsvalueshouldbethevariantshouldbeandthereasonshouldbe(bool expectedValue, string expectedVariant, string expectedReason) + { + var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.Equal(expectedValue, result?.Value); + Assert.Equal(expectedVariant, result?.Variant); + Assert.Equal(expectedReason, result?.Reason); + } + + [When(@"a string flag with key ""(.*)"" is evaluated with details and default value ""(.*)""")] + public async Task Whenastringflagwithkeyisevaluatedwithdetailsanddefaultvalue(string flagKey, string defaultValue) + { + this.State.Flag = new FlagState(flagKey, defaultValue, FlagType.String); + this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetStringDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); + } + + [Then(@"the resolved string details value should be ""(.*)"", the variant should be ""(.*)"", and the reason should be ""(.*)""")] + public void Thentheresolvedstringdetailsvalueshouldbethevariantshouldbeandthereasonshouldbe(string expectedValue, string expectedVariant, string expectedReason) + { + var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.Equal(expectedValue, result?.Value); + Assert.Equal(expectedVariant, result?.Variant); + Assert.Equal(expectedReason, result?.Reason); + } + + [When(@"an integer flag with key ""(.*)"" is evaluated with details and default value (.*)")] + public async Task Whenanintegerflagwithkeyisevaluatedwithdetailsanddefaultvalue(string flagKey, int defaultValue) + { + this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Integer); + this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetIntegerDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); + } + + [Then(@"the resolved integer details value should be (.*), the variant should be ""(.*)"", and the reason should be ""(.*)""")] + public void Thentheresolvedintegerdetailsvalueshouldbethevariantshouldbeandthereasonshouldbe(int expectedValue, string expectedVariant, string expectedReason) + { + var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.Equal(expectedValue, result?.Value); + Assert.Equal(expectedVariant, result?.Variant); + Assert.Equal(expectedReason, result?.Reason); + } + + [When(@"a float flag with key ""(.*)"" is evaluated with details and default value (.*)")] + public async Task Whenafloatflagwithkeyisevaluatedwithdetailsanddefaultvalue(string flagKey, double defaultValue) + { + this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Float); + this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetDoubleDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); + } + + [Then(@"the resolved float details value should be (.*), the variant should be ""(.*)"", and the reason should be ""(.*)""")] + public void Thentheresolvedfloatdetailsvalueshouldbethevariantshouldbeandthereasonshouldbe(double expectedValue, string expectedVariant, string expectedReason) + { + var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.Equal(expectedValue, result?.Value); + Assert.Equal(expectedVariant, result?.Variant); + Assert.Equal(expectedReason, result?.Reason); + } + + [When(@"an object flag with key ""(.*)"" is evaluated with details and a null default value")] + public async Task Whenanobjectflagwithkeyisevaluatedwithdetailsandanulldefaultvalue(string flagKey) + { + this.State.Flag = new FlagState(flagKey, null!, FlagType.Object); + this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetObjectDetailsAsync(flagKey, new Value()).ConfigureAwait(false); + } + + [Then(@"the resolved object details value should be contain fields ""(.*)"", ""(.*)"", and ""(.*)"", with values ""(.*)"", ""(.*)"" and (.*), respectively")] + public void Thentheresolvedobjectdetailsvalueshouldbecontainfieldsandwithvaluesandrespectively(string boolField, string stringField, string numberField, bool boolValue, string stringValue, int numberValue) + { + var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var value = result?.Value; + Assert.NotNull(value); + Assert.Equal(boolValue, value?.AsStructure?[boolField].AsBoolean); + Assert.Equal(stringValue, value?.AsStructure?[stringField].AsString); + Assert.Equal(numberValue, value?.AsStructure?[numberField].AsInteger); + } + + [Then(@"the variant should be ""(.*)"", and the reason should be ""(.*)""")] + public void Giventhevariantshouldbeandthereasonshouldbe(string expectedVariant, string expectedReason) + { + var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(result); + Assert.Equal(expectedVariant, result?.Variant); + Assert.Equal(expectedReason, result?.Reason); + } + + [When(@"context contains keys ""(.*)"", ""(.*)"", ""(.*)"", ""(.*)"" with values ""(.*)"", ""(.*)"", (.*), ""(.*)""")] + public void Whencontextcontainskeyswithvalues(string field1, string field2, string field3, string field4, string value1, string value2, int value3, string value4) + { + this.State.EvaluationContext = new EvaluationContextBuilder() + .Set(field1, value1) + .Set(field2, value2) + .Set(field3, value3) + .Set(field4, bool.Parse(value4)).Build(); + } + + [When(@"a flag with key ""(.*)"" is evaluated with default value ""(.*)""")] + public async Task Givenaflagwithkeyisevaluatedwithdefaultvalue(string flagKey, string defaultValue) + { + this.State.Flag = new FlagState(flagKey, defaultValue, FlagType.String); + this.State.FlagResult = await this.State.Client!.GetStringValueAsync(flagKey, defaultValue, this.State.EvaluationContext).ConfigureAwait(false); + } + + [Then(@"the resolved string response should be ""(.*)""")] + public void Thentheresolvedstringresponseshouldbe(string expected) + { + var result = this.State.FlagResult as string; + Assert.Equal(expected, result); + } + + [Then(@"the resolved flag value is ""(.*)"" when the context is empty")] + public async Task Giventheresolvedflagvalueiswhenthecontextisempty(string expected) + { + var key = this.State.Flag!.Key; + var defaultValue = this.State.Flag.DefaultValue; + + string? emptyContextValue = await this.State.Client!.GetStringValueAsync(key, defaultValue, EvaluationContext.Empty).ConfigureAwait(false); + Assert.Equal(expected, emptyContextValue); + } + + [When(@"a non-existent string flag with key ""(.*)"" is evaluated with details and a default value ""(.*)""")] + public async Task Whenanonexistentstringflagwithkeyisevaluatedwithdetailsandadefaultvalue(string flagKey, string defaultValue) + { + this.State.Flag = new FlagState(flagKey, defaultValue, FlagType.String); + this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetStringDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); + } + + [Then(@"the default string value should be returned")] + public void Thenthedefaultstringvalueshouldbereturned() + { + var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var defaultValue = this.State.Flag!.DefaultValue; + Assert.Equal(defaultValue, result?.Value); + } + + [Then(@"the reason should indicate an error and the error code should indicate a missing flag with ""(.*)""")] + public void Giventhereasonshouldindicateanerrorandtheerrorcodeshouldindicateamissingflagwith(string errorCode) + { + var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.Equal(Reason.Error, result?.Reason); + Assert.Equal(errorCode, result?.ErrorType.GetDescription()); + } + + [When(@"a string flag with key ""(.*)"" is evaluated as an integer, with details and a default value (.*)")] + public async Task Whenastringflagwithkeyisevaluatedasanintegerwithdetailsandadefaultvalue(string flagKey, int defaultValue) + { + this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.String); + this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetIntegerDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); + } + + [Then(@"the default integer value should be returned")] + public void Thenthedefaultintegervalueshouldbereturned() + { + var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var defaultValue = int.Parse(this.State.Flag!.DefaultValue); + Assert.Equal(defaultValue, result?.Value); + } + + [Then(@"the reason should indicate an error and the error code should indicate a type mismatch with ""(.*)""")] + public void Giventhereasonshouldindicateanerrorandtheerrorcodeshouldindicateatypemismatchwith(string errorCode) + { + var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.Equal(Reason.Error, result?.Reason); + Assert.Equal(errorCode, result?.ErrorType.GetDescription()); + } +} diff --git a/test/OpenFeature.E2ETests/Steps/HooksStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/HooksStepDefinitions.cs new file mode 100644 index 00000000..c8882baa --- /dev/null +++ b/test/OpenFeature.E2ETests/Steps/HooksStepDefinitions.cs @@ -0,0 +1,134 @@ +using OpenFeature.E2ETests.Utils; +using Reqnroll; +using Xunit; + +namespace OpenFeature.E2ETests.Steps; + +[Binding] +[Scope(Feature = "Evaluation details through hooks")] +public class HooksStepDefinitions : BaseStepDefinitions +{ + public HooksStepDefinitions(State state) : base(state) + { + } + + [Given(@"a client with added hook")] + public void GivenAClientWithAddedHook() + { + this.State.TestHook = new TestHook(); + this.State.Client!.AddHooks(this.State.TestHook); + } + + [Then(@"the ""(.*)"" hook should have been executed")] + public void ThenTheHookShouldHaveBeenExecuted(string hook) + { + this.CheckHookExecution(hook); + } + + [Then(@"the ""(.*)"" hooks should be called with evaluation details")] + public void ThenTheHooksShouldBeCalledWithEvaluationDetails(string hook, Table table) + { + this.CheckHookExecution(hook); + var key = table.Rows[0]["value"]; + switch (key) + { + case "boolean-flag": + CheckCorrectFlag(table); + break; + case "missing-flag": + CheckMissingFlag(table); + break; + case "wrong-flag": + this.CheckWrongFlag(table); + break; + } + } + + private static void CheckCorrectFlag(Table table) + { + Assert.Equal("string", table.Rows[0]["data_type"]); + Assert.Equal("flag_key", table.Rows[0]["key"]); + Assert.Equal("boolean-flag", table.Rows[0]["value"]); + + Assert.Equal("boolean", table.Rows[1]["data_type"]); + Assert.Equal("value", table.Rows[1]["key"]); + Assert.Equal("true", table.Rows[1]["value"]); + + Assert.Equal("string", table.Rows[2]["data_type"]); + Assert.Equal("variant", table.Rows[2]["key"]); + Assert.Equal("on", table.Rows[2]["value"]); + + Assert.Equal("string", table.Rows[3]["data_type"]); + Assert.Equal("reason", table.Rows[3]["key"]); + Assert.Equal("STATIC", table.Rows[3]["value"]); + + Assert.Equal("string", table.Rows[4]["data_type"]); + Assert.Equal("error_code", table.Rows[4]["key"]); + Assert.Equal("null", table.Rows[4]["value"]); + } + + private static void CheckMissingFlag(Table table) + { + Assert.Equal("string", table.Rows[0]["data_type"]); + Assert.Equal("flag_key", table.Rows[0]["key"]); + Assert.Equal("missing-flag", table.Rows[0]["value"]); + + Assert.Equal("string", table.Rows[1]["data_type"]); + Assert.Equal("value", table.Rows[1]["key"]); + Assert.Equal("uh-oh", table.Rows[1]["value"]); + + Assert.Equal("string", table.Rows[2]["data_type"]); + Assert.Equal("variant", table.Rows[2]["key"]); + Assert.Equal("null", table.Rows[2]["value"]); + + Assert.Equal("string", table.Rows[3]["data_type"]); + Assert.Equal("reason", table.Rows[3]["key"]); + Assert.Equal("ERROR", table.Rows[3]["value"]); + + Assert.Equal("string", table.Rows[4]["data_type"]); + Assert.Equal("error_code", table.Rows[4]["key"]); + Assert.Equal("FLAG_NOT_FOUND", table.Rows[4]["value"]); + } + + private void CheckWrongFlag(Table table) + { + Assert.Equal("string", table.Rows[0]["data_type"]); + Assert.Equal("flag_key", table.Rows[0]["key"]); + Assert.Equal("wrong-flag", table.Rows[0]["value"]); + + Assert.Equal("boolean", table.Rows[1]["data_type"]); + Assert.Equal("value", table.Rows[1]["key"]); + Assert.Equal("false", table.Rows[1]["value"]); + + Assert.Equal("string", table.Rows[2]["data_type"]); + Assert.Equal("variant", table.Rows[2]["key"]); + Assert.Equal("null", table.Rows[2]["value"]); + + Assert.Equal("string", table.Rows[3]["data_type"]); + Assert.Equal("reason", table.Rows[3]["key"]); + Assert.Equal("ERROR", table.Rows[3]["value"]); + + Assert.Equal("string", table.Rows[4]["data_type"]); + Assert.Equal("error_code", table.Rows[4]["key"]); + Assert.Equal("TYPE_MISMATCH", table.Rows[4]["value"]); + } + + private void CheckHookExecution(string hook) + { + switch (hook) + { + case "before": + Assert.Equal(1, this.State.TestHook!.BeforeCount); + break; + case "after": + Assert.Equal(1, this.State.TestHook!.AfterCount); + break; + case "error": + Assert.Equal(1, this.State.TestHook!.ErrorCount); + break; + case "finally": + Assert.Equal(1, this.State.TestHook!.FinallyCount); + break; + } + } +} diff --git a/test/OpenFeature.E2ETests/Steps/MetadataStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/MetadataStepDefinitions.cs new file mode 100644 index 00000000..63c8cdbe --- /dev/null +++ b/test/OpenFeature.E2ETests/Steps/MetadataStepDefinitions.cs @@ -0,0 +1,74 @@ +using System; +using System.Linq; +using OpenFeature.E2ETests.Utils; +using OpenFeature.Model; +using Reqnroll; +using Xunit; + +namespace OpenFeature.E2ETests.Steps; + +[Binding] +[Scope(Feature = "Metadata")] +public class MetadataStepDefinitions : BaseStepDefinitions +{ + MetadataStepDefinitions(State state) : base(state) + { + } + + [Then("the resolved metadata should contain")] + [Scope(Scenario = "Returns metadata")] + public void ThenTheResolvedMetadataShouldContain(DataTable itemsTable) + { + var items = itemsTable.Rows.Select(row => new DataTableRows(row["key"], row["value"], row["metadata_type"])).ToList(); + var metadata = (this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails)?.FlagMetadata; + + foreach (var item in items) + { + var key = item.Key; + var value = item.Value; + var metadataType = item.MetadataType; + + string? actual = null!; + switch (metadataType) + { + case FlagType.Boolean: + actual = metadata!.GetBool(key).ToString(); + break; + case FlagType.Integer: + actual = metadata!.GetInt(key).ToString(); + break; + case FlagType.Float: + actual = metadata!.GetDouble(key).ToString(); + break; + case FlagType.String: + actual = metadata!.GetString(key); + break; + } + + Assert.Equal(value.ToLowerInvariant(), actual?.ToLowerInvariant()); + } + } + + [Then("the resolved metadata is empty")] + public void ThenTheResolvedMetadataIsEmpty() + { + var flag = this.State.Flag!; + switch (flag.Type) + { + case FlagType.Boolean: + Assert.Null((this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails)?.FlagMetadata?.Count); + break; + case FlagType.Float: + Assert.Null((this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails)?.FlagMetadata?.Count); + break; + case FlagType.Integer: + Assert.Null((this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails)?.FlagMetadata?.Count); + break; + case FlagType.String: + Assert.Null((this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails)?.FlagMetadata?.Count); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } +} diff --git a/test/OpenFeature.E2ETests/Utils/ContextStoringProvider.cs b/test/OpenFeature.E2ETests/Utils/ContextStoringProvider.cs new file mode 100644 index 00000000..40141e79 --- /dev/null +++ b/test/OpenFeature.E2ETests/Utils/ContextStoringProvider.cs @@ -0,0 +1,46 @@ +using System.Threading; +using System.Threading.Tasks; +using OpenFeature.Model; + +namespace OpenFeature.E2ETests.Utils; + +public class ContextStoringProvider : FeatureProvider +{ + private EvaluationContext? evaluationContext; + public EvaluationContext? EvaluationContext { get => this.evaluationContext; } + + public override Metadata? GetMetadata() + { + return new Metadata("ContextStoringProvider"); + } + + public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + this.evaluationContext = context; + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } + + public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + this.evaluationContext = context; + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } + + public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + this.evaluationContext = context; + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } + + public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + this.evaluationContext = context; + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } + + public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + this.evaluationContext = context; + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } +} diff --git a/test/OpenFeature.E2ETests/Utils/DataTableRows.cs b/test/OpenFeature.E2ETests/Utils/DataTableRows.cs new file mode 100644 index 00000000..45e43cc5 --- /dev/null +++ b/test/OpenFeature.E2ETests/Utils/DataTableRows.cs @@ -0,0 +1,16 @@ +using OpenFeature.E2ETests.Utils; + +internal class DataTableRows +{ + public DataTableRows(string key, string value, string metadataType) + { + this.Key = key; + this.Value = value; + + this.MetadataType = FlagTypesUtil.ToEnum(metadataType); + } + + public string Key { get; } + public string Value { get; } + public FlagType MetadataType { get; } +} diff --git a/test/OpenFeature.E2ETests/Utils/FlagState.cs b/test/OpenFeature.E2ETests/Utils/FlagState.cs new file mode 100644 index 00000000..375ab55d --- /dev/null +++ b/test/OpenFeature.E2ETests/Utils/FlagState.cs @@ -0,0 +1,15 @@ +namespace OpenFeature.E2ETests.Utils; + +public class FlagState +{ + public FlagState(string key, string defaultValue, FlagType type) + { + this.Key = key; + this.DefaultValue = defaultValue; + this.Type = type; + } + + public string Key { get; private set; } + public string DefaultValue { get; private set; } + public FlagType Type { get; private set; } +} diff --git a/test/OpenFeature.E2ETests/Utils/FlagTypesUtil.cs b/test/OpenFeature.E2ETests/Utils/FlagTypesUtil.cs new file mode 100644 index 00000000..5b05c799 --- /dev/null +++ b/test/OpenFeature.E2ETests/Utils/FlagTypesUtil.cs @@ -0,0 +1,29 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace OpenFeature.E2ETests.Utils; + +[ExcludeFromCodeCoverage] +internal static class FlagTypesUtil +{ + internal static FlagType ToEnum(string flagType) + { + return flagType.ToLowerInvariant() switch + { + "boolean" => FlagType.Boolean, + "float" => FlagType.Float, + "integer" => FlagType.Integer, + "string" => FlagType.String, + _ => throw new ArgumentException("Invalid flag type") + }; + } +} + +public enum FlagType +{ + Integer, + Float, + String, + Boolean, + Object +} diff --git a/test/OpenFeature.E2ETests/Utils/State.cs b/test/OpenFeature.E2ETests/Utils/State.cs new file mode 100644 index 00000000..13a4e5a3 --- /dev/null +++ b/test/OpenFeature.E2ETests/Utils/State.cs @@ -0,0 +1,16 @@ +using OpenFeature.Model; + +namespace OpenFeature.E2ETests.Utils; + +public class State +{ + public FeatureClient? Client; + public FlagState? Flag; + public object? FlagEvaluationDetailsResult; + public TestHook? TestHook; + public object? FlagResult; + public EvaluationContext? EvaluationContext; + public ContextStoringProvider? ContextStoringProvider; + public EvaluationContext? InvocationEvaluationContext; + public string[]? ContextPrecedenceLevels; +} diff --git a/test/OpenFeature.E2ETests/Utils/TestHook.cs b/test/OpenFeature.E2ETests/Utils/TestHook.cs new file mode 100644 index 00000000..fbe7568b --- /dev/null +++ b/test/OpenFeature.E2ETests/Utils/TestHook.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using OpenFeature.Model; + +namespace OpenFeature.E2ETests.Utils; + +[ExcludeFromCodeCoverage] +public class TestHook : Hook +{ + private int _afterCount; + private int _beforeCount; + private int _errorCount; + private int _finallyCount; + + public override ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, + IReadOnlyDictionary? hints = null, + CancellationToken cancellationToken = default) + { + this._afterCount++; + return base.AfterAsync(context, details, hints, cancellationToken); + } + + public override ValueTask ErrorAsync(HookContext context, Exception error, + IReadOnlyDictionary? hints = null, + CancellationToken cancellationToken = default) + { + this._errorCount++; + return base.ErrorAsync(context, error, hints, cancellationToken); + } + + public override ValueTask FinallyAsync(HookContext context, FlagEvaluationDetails evaluationDetails, + IReadOnlyDictionary? hints = null, + CancellationToken cancellationToken = default) + { + this._finallyCount++; + return base.FinallyAsync(context, evaluationDetails, hints, cancellationToken); + } + + public override ValueTask BeforeAsync(HookContext context, + IReadOnlyDictionary? hints = null, + CancellationToken cancellationToken = default) + { + this._beforeCount++; + return base.BeforeAsync(context, hints, cancellationToken); + } + + public int AfterCount => this._afterCount; + public int BeforeCount => this._beforeCount; + public int ErrorCount => this._errorCount; + public int FinallyCount => this._finallyCount; +} diff --git a/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs b/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs new file mode 100644 index 00000000..9e1f4bca --- /dev/null +++ b/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs @@ -0,0 +1,184 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging.Testing; +using OpenFeature.DependencyInjection.Providers.Memory; +using OpenFeature.Hooks; +using OpenFeature.IntegrationTests.Services; +using OpenFeature.Providers.Memory; + +namespace OpenFeature.IntegrationTests; + +public class FeatureFlagIntegrationTest +{ + // TestUserId is "off", other users are "on" + private const string FeatureA = "feature-a"; + private const string TestUserId = "123"; + + [Theory] + [InlineData(TestUserId, false, ServiceLifetime.Singleton)] + [InlineData(TestUserId, false, ServiceLifetime.Scoped)] + [InlineData(TestUserId, false, ServiceLifetime.Transient)] + [InlineData("SomeOtherId", true, ServiceLifetime.Singleton)] + [InlineData("SomeOtherId", true, ServiceLifetime.Scoped)] + [InlineData("SomeOtherId", true, ServiceLifetime.Transient)] + public async Task VerifyFeatureFlagBehaviorAcrossServiceLifetimesAsync(string userId, bool expectedResult, ServiceLifetime serviceLifetime) + { + // Arrange + var logger = new FakeLogger(); + using var server = await CreateServerAsync(serviceLifetime, logger, services => + { + switch (serviceLifetime) + { + case ServiceLifetime.Singleton: + services.AddSingleton(); + break; + case ServiceLifetime.Scoped: + services.AddScoped(); + break; + case ServiceLifetime.Transient: + services.AddTransient(); + break; + default: + throw new ArgumentOutOfRangeException(nameof(serviceLifetime), serviceLifetime, null); + } + }).ConfigureAwait(true); + + var client = server.CreateClient(); + var requestUri = $"/features/{userId}/flags/{FeatureA}"; + + // Act + var response = await client.GetAsync(requestUri).ConfigureAwait(true); + var responseContent = await response.Content.ReadFromJsonAsync>().ConfigureAwait(true); + + // Assert + Assert.True(response.IsSuccessStatusCode, "Expected HTTP status code 200 OK."); + Assert.NotNull(responseContent); + Assert.Equal(FeatureA, responseContent!.FeatureName); + Assert.Equal(expectedResult, responseContent.FeatureValue); + } + + [Fact] + public async Task VerifyLoggingHookIsRegisteredAsync() + { + // Arrange + var logger = new FakeLogger(); + using var server = await CreateServerAsync(ServiceLifetime.Transient, logger, services => + { + services.AddTransient(); + }).ConfigureAwait(true); + + var client = server.CreateClient(); + var requestUri = $"/features/{TestUserId}/flags/{FeatureA}"; + + // Act + var response = await client.GetAsync(requestUri).ConfigureAwait(true); + var logs = logger.Collector.GetSnapshot(); + + // Assert + Assert.True(response.IsSuccessStatusCode, "Expected HTTP status code 200 OK."); + Assert.Equal(4, logs.Count); + Assert.Multiple(() => + { + Assert.Contains("Before Flag Evaluation", logs[0].Message); + Assert.Contains("After Flag Evaluation", logs[1].Message); + }); + } + + private static async Task CreateServerAsync(ServiceLifetime serviceLifetime, FakeLogger logger, + Action? configureServices = null) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + configureServices?.Invoke(builder.Services); + builder.Services.TryAddSingleton(); + + builder.Services.AddHttpContextAccessor(); + builder.Services.AddOpenFeature(cfg => + { + cfg.AddHostedFeatureLifecycle(); + cfg.AddContext((builder, provider) => + { + // Retrieve the HttpContext from IHttpContextAccessor, ensuring it's not null. + var context = provider.GetRequiredService().HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + + var userId = UserInfoHelper.GetUserId(context); + builder.Set("user", userId); + }); + cfg.AddInMemoryProvider(provider => + { + if (serviceLifetime == ServiceLifetime.Scoped) + { + using var scoped = provider.CreateScope(); + var flagService = scoped.ServiceProvider.GetRequiredService(); + return flagService.GetFlags(); + } + else + { + var flagService = provider.GetRequiredService(); + return flagService.GetFlags(); + } + }); + cfg.AddHook(serviceProvider => new LoggingHook(logger)); + }); + + var app = builder.Build(); + + app.UseRouting(); + app.Map($"/features/{{userId}}/flags/{{featureName}}", async context => + { + var client = context.RequestServices.GetRequiredService(); + var featureName = UserInfoHelper.GetFeatureName(context); + var res = await client.GetBooleanValueAsync(featureName, false).ConfigureAwait(true); + var result = await client.GetBooleanValueAsync(featureName, false).ConfigureAwait(true); + + var response = new FeatureFlagResponse(featureName, result); + + // Serialize the response object to JSON + var jsonResponse = JsonSerializer.Serialize(response, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + + // Write the JSON response + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(jsonResponse).ConfigureAwait(true); + }); + + await app.StartAsync().ConfigureAwait(true); + + return app.GetTestServer(); + } + + public class FlagConfigurationService : IFeatureFlagConfigurationService + { + private readonly IDictionary _flags; + public FlagConfigurationService() + { + _flags = new Dictionary + { + { + "feature-a", new Flag( + variants: new Dictionary() + { + { "on", true }, + { "off", false } + }, + defaultVariant: "on", context => { + var id = context.GetValue("user").AsString; + if(id == null) + { + return "on"; // default variant + } + + return id == TestUserId ? "off" : "on"; + }) + } + }; + } + public Dictionary GetFlags() => _flags.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + } +} diff --git a/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj b/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj new file mode 100644 index 00000000..151c61b9 --- /dev/null +++ b/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj @@ -0,0 +1,34 @@ + + + + net9.0 + enable + enable + + false + true + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + diff --git a/test/OpenFeature.IntegrationTests/Services/FeatureFlagResponse.cs b/test/OpenFeature.IntegrationTests/Services/FeatureFlagResponse.cs new file mode 100644 index 00000000..50285cc0 --- /dev/null +++ b/test/OpenFeature.IntegrationTests/Services/FeatureFlagResponse.cs @@ -0,0 +1,3 @@ +namespace OpenFeature.IntegrationTests.Services; + +public record FeatureFlagResponse(string FeatureName, T FeatureValue) where T : notnull; diff --git a/test/OpenFeature.IntegrationTests/Services/IFeatureFlagConfigurationService.cs b/test/OpenFeature.IntegrationTests/Services/IFeatureFlagConfigurationService.cs new file mode 100644 index 00000000..1b51a60a --- /dev/null +++ b/test/OpenFeature.IntegrationTests/Services/IFeatureFlagConfigurationService.cs @@ -0,0 +1,8 @@ +using OpenFeature.Providers.Memory; + +namespace OpenFeature.IntegrationTests.Services; + +internal interface IFeatureFlagConfigurationService +{ + Dictionary GetFlags(); +} diff --git a/test/OpenFeature.IntegrationTests/Services/UserInfo.cs b/test/OpenFeature.IntegrationTests/Services/UserInfo.cs new file mode 100644 index 00000000..c2c5d8c1 --- /dev/null +++ b/test/OpenFeature.IntegrationTests/Services/UserInfo.cs @@ -0,0 +1,3 @@ +namespace OpenFeature.IntegrationTests.Services; + +public record UserInfo(string UserId, string FeatureName); diff --git a/test/OpenFeature.IntegrationTests/Services/UserInfoHelper.cs b/test/OpenFeature.IntegrationTests/Services/UserInfoHelper.cs new file mode 100644 index 00000000..0e057a6b --- /dev/null +++ b/test/OpenFeature.IntegrationTests/Services/UserInfoHelper.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Http; + +namespace OpenFeature.IntegrationTests.Services; + +public static class UserInfoHelper +{ + /// + /// Extracts the user ID from the HTTP request context. + /// + /// The HTTP context containing the request. + /// The user ID as a string. + /// Thrown if the user ID is not found in the route values. + public static string GetUserId(HttpContext context) + { + if (context.Request.RouteValues.TryGetValue("userId", out var userId) && userId is string userIdString) + { + return userIdString; + } + throw new ArgumentNullException(nameof(userId), "User ID not found in route values."); + } + + /// + /// Extracts the feature name from the HTTP request context. + /// + /// The HTTP context containing the request. + /// The feature name as a string. + /// Thrown if the feature name is not found in the route values. + public static string GetFeatureName(HttpContext context) + { + if (context.Request.RouteValues.TryGetValue("featureName", out var featureName) && featureName is string featureNameString) + { + return featureNameString; + } + throw new ArgumentNullException(nameof(featureName), "Feature name not found in route values."); + } +} diff --git a/test/OpenFeature.Tests/AsyncLocalTransactionContextPropagatorTests.cs b/test/OpenFeature.Tests/AsyncLocalTransactionContextPropagatorTests.cs new file mode 100644 index 00000000..51c94bfb --- /dev/null +++ b/test/OpenFeature.Tests/AsyncLocalTransactionContextPropagatorTests.cs @@ -0,0 +1,58 @@ +using OpenFeature.Model; +using Xunit; + +namespace OpenFeature.Tests; + +public class AsyncLocalTransactionContextPropagatorTests +{ + [Fact] + public void GetTransactionContext_ReturnsEmpty_WhenNoContextIsSet() + { + // Arrange + var propagator = new AsyncLocalTransactionContextPropagator(); + + // Act + var context = propagator.GetTransactionContext(); + + // Assert + Assert.Equal(EvaluationContext.Empty, context); + } + + [Fact] + public void SetTransactionContext_SetsAndGetsContextCorrectly() + { + // Arrange + var propagator = new AsyncLocalTransactionContextPropagator(); + var evaluationContext = EvaluationContext.Builder() + .Set("initial", "yes") + .Build(); + + // Act + propagator.SetTransactionContext(evaluationContext); + var context = propagator.GetTransactionContext(); + + // Assert + Assert.Equal(evaluationContext, context); + Assert.Equal(evaluationContext.GetValue("initial"), context.GetValue("initial")); + } + + [Fact] + public void SetTransactionContext_OverridesPreviousContext() + { + // Arrange + var propagator = new AsyncLocalTransactionContextPropagator(); + + var initialContext = EvaluationContext.Builder() + .Set("initial", "yes") + .Build(); + var newContext = EvaluationContext.Empty; + + // Act + propagator.SetTransactionContext(initialContext); + propagator.SetTransactionContext(newContext); + var context = propagator.GetTransactionContext(); + + // Assert + Assert.Equal(newContext, context); + } +} diff --git a/test/OpenFeature.Tests/ClearOpenFeatureInstanceFixture.cs b/test/OpenFeature.Tests/ClearOpenFeatureInstanceFixture.cs new file mode 100644 index 00000000..c3351801 --- /dev/null +++ b/test/OpenFeature.Tests/ClearOpenFeatureInstanceFixture.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using Xunit; + +namespace OpenFeature.Tests; + +public class ClearOpenFeatureInstanceFixture : IAsyncLifetime +{ + public Task InitializeAsync() + { + Api.ResetApi(); + + return Task.CompletedTask; + } + + // Make sure the singleton is cleared between tests + public async Task DisposeAsync() + { + await Api.Instance.ShutdownAsync().ConfigureAwait(false); + } +} diff --git a/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs b/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs index 8d100176..334f664e 100644 --- a/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs +++ b/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs @@ -1,34 +1,67 @@ using System; -using FluentAssertions; -using OpenFeature.SDK.Constant; -using OpenFeature.SDK.Error; +using OpenFeature.Constant; +using OpenFeature.Error; +using OpenFeature.Extension; using Xunit; -namespace OpenFeature.SDK.Tests +namespace OpenFeature.Tests; + +public class FeatureProviderExceptionTests { - public class FeatureProviderExceptionTests + [Theory] + [InlineData(ErrorType.General, "GENERAL")] + [InlineData(ErrorType.ParseError, "PARSE_ERROR")] + [InlineData(ErrorType.TypeMismatch, "TYPE_MISMATCH")] + [InlineData(ErrorType.FlagNotFound, "FLAG_NOT_FOUND")] + [InlineData(ErrorType.ProviderNotReady, "PROVIDER_NOT_READY")] + public void FeatureProviderException_Should_Resolve_Description(ErrorType errorType, string errorDescription) + { + var ex = new FeatureProviderException(errorType); + + Assert.Equal(errorDescription, ex.ErrorType.GetDescription()); + } + + [Theory] + [InlineData(ErrorType.General, "Subscription has expired, please renew your subscription.")] + [InlineData(ErrorType.ProviderNotReady, "User has exceeded the quota for this feature.")] + public void FeatureProviderException_Should_Allow_Custom_ErrorCode_Messages(ErrorType errorCode, string message) { - [Theory] - [InlineData(ErrorType.General, "GENERAL")] - [InlineData(ErrorType.ParseError, "PARSE_ERROR")] - [InlineData(ErrorType.TypeMismatch, "TYPE_MISMATCH")] - [InlineData(ErrorType.FlagNotFound, "FLAG_NOT_FOUND")] - [InlineData(ErrorType.ProviderNotReady, "PROVIDER_NOT_READY")] - public void FeatureProviderException_Should_Resolve_Description(ErrorType errorType, string errorDescription) - { - var ex = new FeatureProviderException(errorType); - ex.ErrorDescription.Should().Be(errorDescription); - } - - [Theory] - [InlineData("OUT_OF_CREDIT", "Subscription has expired, please renew your subscription.")] - [InlineData("Exceed quota", "User has exceeded the quota for this feature.")] - public void FeatureProviderException_Should_Allow_Custom_ErrorCode_Messages(string errorCode, string message) - { - var ex = new FeatureProviderException(errorCode, message, new ArgumentOutOfRangeException("flag")); - ex.ErrorDescription.Should().Be(errorCode); - ex.Message.Should().Be(message); - ex.InnerException.Should().BeOfType(); - } + var ex = new FeatureProviderException(errorCode, message, new ArgumentOutOfRangeException("flag")); + + Assert.Equal(errorCode, ex.ErrorType); + Assert.Equal(message, ex.Message); + Assert.IsType(ex.InnerException); + } + + private enum TestEnum + { + TestValueWithoutDescription + } + + [Fact] + public void GetDescription_WhenCalledWithEnumWithoutDescription_ReturnsEnumName() + { + // Arrange + var testEnum = TestEnum.TestValueWithoutDescription; + var expectedDescription = "TestValueWithoutDescription"; + + // Act + var actualDescription = testEnum.GetDescription(); + + // Assert + Assert.Equal(expectedDescription, actualDescription); + } + + [Fact] + public void GetDescription_WhenFieldIsNull_ReturnsEnumValueAsString() + { + // Arrange + var testEnum = (TestEnum)999;// This value should not exist in the TestEnum + + // Act + var description = testEnum.GetDescription(); + + // Assert + Assert.Equal(testEnum.ToString(), description); } } diff --git a/test/OpenFeature.Tests/FeatureProviderTests.cs b/test/OpenFeature.Tests/FeatureProviderTests.cs index 29bfa44f..d7e5ca22 100644 --- a/test/OpenFeature.Tests/FeatureProviderTests.cs +++ b/test/OpenFeature.Tests/FeatureProviderTests.cs @@ -1,88 +1,135 @@ using System.Threading.Tasks; using AutoFixture; -using FluentAssertions; -using Moq; -using OpenFeature.SDK.Constant; -using OpenFeature.SDK.Model; -using OpenFeature.SDK.Tests.Internal; +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Tests.Internal; using Xunit; -namespace OpenFeature.SDK.Tests +namespace OpenFeature.Tests; + +public class FeatureProviderTests : ClearOpenFeatureInstanceFixture { - public class FeatureProviderTests + [Fact] + [Specification("2.1.1", "The provider interface MUST define a `metadata` member or accessor, containing a `name` field or accessor of type string, which identifies the provider implementation.")] + public void Provider_Must_Have_Metadata() + { + var provider = new TestProvider(); + + Assert.Equal(TestProvider.DefaultName, provider.GetMetadata().Name); + } + + [Fact] + [Specification("2.2.1", "The `feature provider` interface MUST define methods to resolve flag values, with parameters `flag key` (string, required), `default value` (boolean | number | string | structure, required) and `evaluation context` (optional), which returns a `resolution details` structure.")] + [Specification("2.2.2.1", "The `feature provider` interface MUST define methods for typed flag resolution, including boolean, numeric, string, and structure.")] + [Specification("2.2.3", "In cases of normal execution, the `provider` MUST populate the `resolution details` structure's `value` field with the resolved flag value.")] + [Specification("2.2.4", "In cases of normal execution, the `provider` SHOULD populate the `resolution details` structure's `variant` field with a string identifier corresponding to the returned flag value.")] + [Specification("2.2.5", "The `provider` SHOULD populate the `resolution details` structure's `reason` field with `\"STATIC\"`, `\"DEFAULT\",` `\"TARGETING_MATCH\"`, `\"SPLIT\"`, `\"CACHED\"`, `\"DISABLED\"`, `\"UNKNOWN\"`, `\"ERROR\"` or some other string indicating the semantic reason for the returned flag value.")] + [Specification("2.2.6", "In cases of normal execution, the `provider` MUST NOT populate the `resolution details` structure's `error code` field, or otherwise must populate it with a null or falsy value.")] + [Specification("2.2.8.1", "The `resolution details` structure SHOULD accept a generic argument (or use an equivalent language feature) which indicates the type of the wrapped `value` field.")] + [Specification("2.3.2", "In cases of normal execution, the `provider` MUST NOT populate the `resolution details` structure's `error message` field, or otherwise must populate it with a null or falsy value.")] + public async Task Provider_Must_Resolve_Flag_Values() + { + var fixture = new Fixture(); + var flagName = fixture.Create(); + var defaultBoolValue = fixture.Create(); + var defaultStringValue = fixture.Create(); + var defaultIntegerValue = fixture.Create(); + var defaultDoubleValue = fixture.Create(); + var defaultStructureValue = fixture.Create(); + var provider = new NoOpFeatureProvider(); + + var boolResolutionDetails = new ResolutionDetails(flagName, defaultBoolValue, ErrorType.None, + NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + Assert.Equivalent(boolResolutionDetails, await provider.ResolveBooleanValueAsync(flagName, defaultBoolValue)); + + var integerResolutionDetails = new ResolutionDetails(flagName, defaultIntegerValue, ErrorType.None, + NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + Assert.Equivalent(integerResolutionDetails, await provider.ResolveIntegerValueAsync(flagName, defaultIntegerValue)); + + var doubleResolutionDetails = new ResolutionDetails(flagName, defaultDoubleValue, ErrorType.None, + NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + Assert.Equivalent(doubleResolutionDetails, await provider.ResolveDoubleValueAsync(flagName, defaultDoubleValue)); + + var stringResolutionDetails = new ResolutionDetails(flagName, defaultStringValue, ErrorType.None, + NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + Assert.Equivalent(stringResolutionDetails, await provider.ResolveStringValueAsync(flagName, defaultStringValue)); + + var structureResolutionDetails = new ResolutionDetails(flagName, defaultStructureValue, + ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + Assert.Equivalent(structureResolutionDetails, await provider.ResolveStructureValueAsync(flagName, defaultStructureValue)); + } + + [Fact] + [Specification("2.2.7", "In cases of abnormal execution, the `provider` MUST indicate an error using the idioms of the implementation language, with an associated `error code` and optional associated `error message`.")] + [Specification("2.3.3", "In cases of abnormal execution, the `resolution details` structure's `error message` field MAY contain a string containing additional detail about the nature of the error.")] + public async Task Provider_Must_ErrorType() { - [Fact] - [Specification("2.1", "The provider interface MUST define a `metadata` member or accessor, containing a `name` field or accessor of type string, which identifies the provider implementation.")] - public void Provider_Must_Have_Metadata() - { - var provider = new TestProvider(); - - provider.GetMetadata().Name.Should().Be(TestProvider.Name); - } - - [Fact] - [Specification("2.2", "The `feature provider` interface MUST define methods to resolve flag values, with parameters `flag key` (string, required), `default value` (boolean | number | string | structure, required), `evaluation context` (optional), and `evaluation options` (optional), which returns a `flag resolution` structure.")] - [Specification("2.3.1", "The `feature provider` interface MUST define methods for typed flag resolution, including boolean, numeric, string, and structure.")] - [Specification("2.4", "In cases of normal execution, the `provider` MUST populate the `flag resolution` structure's `value` field with the resolved flag value.")] - [Specification("2.5", "In cases of normal execution, the `provider` SHOULD populate the `flag resolution` structure's `variant` field with a string identifier corresponding to the returned flag value.")] - [Specification("2.6", "The `provider` SHOULD populate the `flag resolution` structure's `reason` field with a string indicating the semantic reason for the returned flag value.")] - [Specification("2.7", "In cases of normal execution, the `provider` MUST NOT populate the `flag resolution` structure's `error code` field, or otherwise must populate it with a null or falsy value.")] - [Specification("2.9", "In cases of normal execution, the `provider` MUST NOT populate the `flag resolution` structure's `error code` field, or otherwise must populate it with a null or falsy value.")] - public async Task Provider_Must_Resolve_Flag_Values() - { - var fixture = new Fixture(); - var flagName = fixture.Create(); - var defaultBoolValue = fixture.Create(); - var defaultStringValue = fixture.Create(); - var defaultNumberValue = fixture.Create(); - var defaultStructureValue = fixture.Create(); - var provider = new NoOpFeatureProvider(); - - var boolResolutionDetails = new ResolutionDetails(flagName, defaultBoolValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); - (await provider.ResolveBooleanValue(flagName, defaultBoolValue)).Should().BeEquivalentTo(boolResolutionDetails); - var numberResolutionDetails = new ResolutionDetails(flagName, defaultNumberValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); - (await provider.ResolveNumberValue(flagName, defaultNumberValue)).Should().BeEquivalentTo(numberResolutionDetails); - var stringResolutionDetails = new ResolutionDetails(flagName, defaultStringValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); - (await provider.ResolveStringValue(flagName, defaultStringValue)).Should().BeEquivalentTo(stringResolutionDetails); - var structureResolutionDetails = new ResolutionDetails(flagName, defaultStructureValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); - (await provider.ResolveStructureValue(flagName, defaultStructureValue)).Should().BeEquivalentTo(structureResolutionDetails); - } - - [Fact] - [Specification("2.8", "In cases of abnormal execution, the `provider` MUST indicate an error using the idioms of the implementation language, with an associated error code having possible values `PROVIDER_NOT_READY`, `FLAG_NOT_FOUND`, `PARSE_ERROR`, `TYPE_MISMATCH`, or `GENERAL`.")] - public async Task Provider_Must_ErrorType() - { - var fixture = new Fixture(); - var flagName = fixture.Create(); - var flagName2 = fixture.Create(); - var defaultBoolValue = fixture.Create(); - var defaultStringValue = fixture.Create(); - var defaultNumberValue = fixture.Create(); - var defaultStructureValue = fixture.Create(); - var providerMock = new Mock(); - - providerMock.Setup(x => x.ResolveBooleanValue(flagName, defaultBoolValue, It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResolutionDetails(flagName, defaultBoolValue, ErrorType.General, NoOpProvider.ReasonNoOp, NoOpProvider.Variant)); - - providerMock.Setup(x => x.ResolveNumberValue(flagName, defaultNumberValue, It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResolutionDetails(flagName, defaultNumberValue, ErrorType.ParseError, NoOpProvider.ReasonNoOp, NoOpProvider.Variant)); - - providerMock.Setup(x => x.ResolveStringValue(flagName, defaultStringValue, It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResolutionDetails(flagName, defaultStringValue, ErrorType.TypeMismatch, NoOpProvider.ReasonNoOp, NoOpProvider.Variant)); - - providerMock.Setup(x => x.ResolveStructureValue(flagName, defaultStructureValue, It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResolutionDetails(flagName, defaultStructureValue, ErrorType.FlagNotFound, NoOpProvider.ReasonNoOp, NoOpProvider.Variant)); - - providerMock.Setup(x => x.ResolveStructureValue(flagName2, defaultStructureValue, It.IsAny(), It.IsAny())) - .ReturnsAsync(new ResolutionDetails(flagName, defaultStructureValue, ErrorType.ProviderNotReady, NoOpProvider.ReasonNoOp, NoOpProvider.Variant)); - - var provider = providerMock.Object; - - (await provider.ResolveBooleanValue(flagName, defaultBoolValue)).ErrorType.Should().Be(ErrorType.General); - (await provider.ResolveNumberValue(flagName, defaultNumberValue)).ErrorType.Should().Be(ErrorType.ParseError); - (await provider.ResolveStringValue(flagName, defaultStringValue)).ErrorType.Should().Be(ErrorType.TypeMismatch); - (await provider.ResolveStructureValue(flagName, defaultStructureValue)).ErrorType.Should().Be(ErrorType.FlagNotFound); - (await provider.ResolveStructureValue(flagName2, defaultStructureValue)).ErrorType.Should().Be(ErrorType.ProviderNotReady); - } + var fixture = new Fixture(); + var flagName = fixture.Create(); + var flagName2 = fixture.Create(); + var defaultBoolValue = fixture.Create(); + var defaultStringValue = fixture.Create(); + var defaultIntegerValue = fixture.Create(); + var defaultDoubleValue = fixture.Create(); + var defaultStructureValue = fixture.Create(); + var providerMock = Substitute.For(); + const string testMessage = "An error message"; + + providerMock.ResolveBooleanValueAsync(flagName, defaultBoolValue, Arg.Any()) + .Returns(new ResolutionDetails(flagName, defaultBoolValue, ErrorType.General, + NoOpProvider.ReasonNoOp, NoOpProvider.Variant, testMessage)); + + providerMock.ResolveIntegerValueAsync(flagName, defaultIntegerValue, Arg.Any()) + .Returns(new ResolutionDetails(flagName, defaultIntegerValue, ErrorType.ParseError, + NoOpProvider.ReasonNoOp, NoOpProvider.Variant, testMessage)); + + providerMock.ResolveDoubleValueAsync(flagName, defaultDoubleValue, Arg.Any()) + .Returns(new ResolutionDetails(flagName, defaultDoubleValue, ErrorType.InvalidContext, + NoOpProvider.ReasonNoOp, NoOpProvider.Variant, testMessage)); + + providerMock.ResolveStringValueAsync(flagName, defaultStringValue, Arg.Any()) + .Returns(new ResolutionDetails(flagName, defaultStringValue, ErrorType.TypeMismatch, + NoOpProvider.ReasonNoOp, NoOpProvider.Variant, testMessage)); + + providerMock.ResolveStructureValueAsync(flagName, defaultStructureValue, Arg.Any()) + .Returns(new ResolutionDetails(flagName, defaultStructureValue, ErrorType.FlagNotFound, + NoOpProvider.ReasonNoOp, NoOpProvider.Variant, testMessage)); + + providerMock.ResolveStructureValueAsync(flagName2, defaultStructureValue, Arg.Any()) + .Returns(new ResolutionDetails(flagName2, defaultStructureValue, ErrorType.ProviderNotReady, + NoOpProvider.ReasonNoOp, NoOpProvider.Variant, testMessage)); + + providerMock.ResolveBooleanValueAsync(flagName2, defaultBoolValue, Arg.Any()) + .Returns(new ResolutionDetails(flagName2, defaultBoolValue, ErrorType.TargetingKeyMissing, + NoOpProvider.ReasonNoOp, NoOpProvider.Variant)); + + var boolRes = await providerMock.ResolveBooleanValueAsync(flagName, defaultBoolValue); + Assert.Equal(ErrorType.General, boolRes.ErrorType); + Assert.Equal(testMessage, boolRes.ErrorMessage); + + var intRes = await providerMock.ResolveIntegerValueAsync(flagName, defaultIntegerValue); + Assert.Equal(ErrorType.ParseError, intRes.ErrorType); + Assert.Equal(testMessage, intRes.ErrorMessage); + + var doubleRes = await providerMock.ResolveDoubleValueAsync(flagName, defaultDoubleValue); + Assert.Equal(ErrorType.InvalidContext, doubleRes.ErrorType); + Assert.Equal(testMessage, doubleRes.ErrorMessage); + + var stringRes = await providerMock.ResolveStringValueAsync(flagName, defaultStringValue); + Assert.Equal(ErrorType.TypeMismatch, stringRes.ErrorType); + Assert.Equal(testMessage, stringRes.ErrorMessage); + + var structRes1 = await providerMock.ResolveStructureValueAsync(flagName, defaultStructureValue); + Assert.Equal(ErrorType.FlagNotFound, structRes1.ErrorType); + Assert.Equal(testMessage, structRes1.ErrorMessage); + + var structRes2 = await providerMock.ResolveStructureValueAsync(flagName2, defaultStructureValue); + Assert.Equal(ErrorType.ProviderNotReady, structRes2.ErrorType); + Assert.Equal(testMessage, structRes2.ErrorMessage); + + var boolRes2 = await providerMock.ResolveBooleanValueAsync(flagName2, defaultBoolValue); + Assert.Equal(ErrorType.TargetingKeyMissing, boolRes2.ErrorType); + Assert.Null(boolRes2.ErrorMessage); } } diff --git a/test/OpenFeature.Tests/HookDataTests.cs b/test/OpenFeature.Tests/HookDataTests.cs new file mode 100644 index 00000000..96cbaf72 --- /dev/null +++ b/test/OpenFeature.Tests/HookDataTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using OpenFeature.Model; +using Xunit; + +namespace OpenFeature.Tests; + +public class HookDataTests +{ + private readonly HookData _commonHookData = new(); + + public HookDataTests() + { + this._commonHookData.Set("bool", true); + this._commonHookData.Set("string", "string"); + this._commonHookData.Set("int", 1); + this._commonHookData.Set("double", 1.2); + this._commonHookData.Set("float", 1.2f); + } + + [Fact] + public void HookData_Can_Set_And_Get_Data() + { + var hookData = new HookData(); + hookData.Set("bool", true); + hookData.Set("string", "string"); + hookData.Set("int", 1); + hookData.Set("double", 1.2); + hookData.Set("float", 1.2f); + var structure = Structure.Builder().Build(); + hookData.Set("structure", structure); + + Assert.True((bool)hookData.Get("bool")); + Assert.Equal("string", hookData.Get("string")); + Assert.Equal(1, hookData.Get("int")); + Assert.Equal(1.2, hookData.Get("double")); + Assert.Equal(1.2f, hookData.Get("float")); + Assert.Same(structure, hookData.Get("structure")); + } + + [Fact] + public void HookData_Can_Chain_Set() + { + var structure = Structure.Builder().Build(); + + var hookData = new HookData(); + hookData.Set("bool", true) + .Set("string", "string") + .Set("int", 1) + .Set("double", 1.2) + .Set("float", 1.2f) + .Set("structure", structure); + + Assert.True((bool)hookData.Get("bool")); + Assert.Equal("string", hookData.Get("string")); + Assert.Equal(1, hookData.Get("int")); + Assert.Equal(1.2, hookData.Get("double")); + Assert.Equal(1.2f, hookData.Get("float")); + Assert.Same(structure, hookData.Get("structure")); + } + + [Fact] + public void HookData_Can_Set_And_Get_Data_Using_Indexer() + { + var hookData = new HookData(); + hookData["bool"] = true; + hookData["string"] = "string"; + hookData["int"] = 1; + hookData["double"] = 1.2; + hookData["float"] = 1.2f; + var structure = Structure.Builder().Build(); + hookData["structure"] = structure; + + Assert.True((bool)hookData["bool"]); + Assert.Equal("string", hookData["string"]); + Assert.Equal(1, hookData["int"]); + Assert.Equal(1.2, hookData["double"]); + Assert.Equal(1.2f, hookData["float"]); + Assert.Same(structure, hookData["structure"]); + } + + [Fact] + public void HookData_Can_Be_Enumerated() + { + var asList = new List>(); + foreach (var kvp in this._commonHookData) + { + asList.Add(kvp); + } + + asList.Sort((a, b) => + string.Compare(a.Key, b.Key, StringComparison.Ordinal)); + + Assert.Equal([ + new KeyValuePair("bool", true), + new KeyValuePair("double", 1.2), + new KeyValuePair("float", 1.2f), + new KeyValuePair("int", 1), + new KeyValuePair("string", "string") + ], asList); + } + + [Fact] + public void HookData_Has_Count() + { + Assert.Equal(5, this._commonHookData.Count); + } + + [Fact] + public void HookData_Has_Keys() + { + Assert.Equal(5, this._commonHookData.Keys.Count); + Assert.Contains("bool", this._commonHookData.Keys); + Assert.Contains("double", this._commonHookData.Keys); + Assert.Contains("float", this._commonHookData.Keys); + Assert.Contains("int", this._commonHookData.Keys); + Assert.Contains("string", this._commonHookData.Keys); + } + + [Fact] + public void HookData_Has_Values() + { + Assert.Equal(5, this._commonHookData.Values.Count); + Assert.Contains(true, this._commonHookData.Values); + Assert.Contains(1, this._commonHookData.Values); + Assert.Contains(1.2f, this._commonHookData.Values); + Assert.Contains(1.2, this._commonHookData.Values); + Assert.Contains("string", this._commonHookData.Values); + } + + [Fact] + public void HookData_Can_Be_Converted_To_Dictionary() + { + var asDictionary = this._commonHookData.AsDictionary(); + Assert.Equal(5, asDictionary.Count); + Assert.Equal(true, asDictionary["bool"]); + Assert.Equal(1.2, asDictionary["double"]); + Assert.Equal(1.2f, asDictionary["float"]); + Assert.Equal(1, asDictionary["int"]); + Assert.Equal("string", asDictionary["string"]); + } + + [Fact] + public void HookData_Get_Should_Throw_When_Key_Not_Found() + { + var hookData = new HookData(); + + Assert.Throws(() => hookData.Get("nonexistent")); + } + + [Fact] + public void HookData_Indexer_Should_Throw_When_Key_Not_Found() + { + var hookData = new HookData(); + + Assert.Throws(() => _ = hookData["nonexistent"]); + } +} diff --git a/test/OpenFeature.Tests/Hooks/LoggingHookTests.cs b/test/OpenFeature.Tests/Hooks/LoggingHookTests.cs new file mode 100644 index 00000000..1364f83f --- /dev/null +++ b/test/OpenFeature.Tests/Hooks/LoggingHookTests.cs @@ -0,0 +1,664 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using OpenFeature.Constant; +using OpenFeature.Hooks; +using OpenFeature.Model; +using Xunit; + +namespace OpenFeature.Tests.Hooks; + +public class LoggingHookTests +{ + [Fact] + public async Task BeforeAsync_Without_EvaluationContext_Generates_Debug_Log() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + + var hook = new LoggingHook(logger, includeContext: false); + + // Act + await hook.BeforeAsync(context); + + // Assert + Assert.Equal(1, logger.Collector.Count); + + var record = logger.LatestRecord; + + Assert.Equal(LogLevel.Debug, record.Level); + Assert.Equal( + """ + Before Flag Evaluation Domain:client + ProviderName:provider + FlagKey:test + DefaultValue:False + + """, + record.Message, + ignoreLineEndingDifferences: true + ); + } + + [Fact] + public async Task BeforeAsync_Without_EvaluationContext_Generates_Correct_Log_Message() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + + var hook = new LoggingHook(logger, includeContext: false); + + // Act + await hook.BeforeAsync(context); + + // Assert + var record = logger.LatestRecord; + + Assert.Equal( + """ + Before Flag Evaluation Domain:client + ProviderName:provider + FlagKey:test + DefaultValue:False + + """, + record.Message, + ignoreLineEndingDifferences: true + ); + } + + [Fact] + public async Task BeforeAsync_With_EvaluationContext_Generates_Correct_Log_Message() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var timestamp = DateTime.Parse("2025-01-01T11:00:00.0000000Z"); + var evaluationContext = EvaluationContext.Builder() + .Set("key_1", "value") + .Set("key_2", false) + .Set("key_3", 1.531) + .Set("key_4", 42) + .Set("key_5", timestamp) + .Build(); + + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, evaluationContext); + + var hook = new LoggingHook(logger, includeContext: true); + + // Act + await hook.BeforeAsync(context); + + // Assert + Assert.Equal(1, logger.Collector.Count); + + var record = logger.LatestRecord; + Assert.Equal(LogLevel.Debug, record.Level); + + Assert.Multiple( + () => Assert.Contains("key_1:value", record.Message), + () => Assert.Contains("key_2:False", record.Message), + () => Assert.Contains("key_3:1.531", record.Message), + () => Assert.Contains("key_4:42", record.Message), + () => Assert.Contains($"key_5:{timestamp:O}", record.Message) + ); + } + + [Fact] + public async Task BeforeAsync_With_No_EvaluationContext_Generates_Correct_Log_Message() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + + // Act + var hook = new LoggingHook(logger, includeContext: true); + + await hook.BeforeAsync(context); + + // Assert + Assert.Equal(1, logger.Collector.Count); + + var record = logger.LatestRecord; + Assert.Equal(LogLevel.Debug, record.Level); + + Assert.Equal( + """ + Before Flag Evaluation Domain:client + ProviderName:provider + FlagKey:test + DefaultValue:False + Context: + + """, + record.Message, + ignoreLineEndingDifferences: true + ); + } + + [Fact] + public async Task ErrorAsync_Without_EvaluationContext_Generates_Error_Log() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + + var hook = new LoggingHook(logger, includeContext: false); + + var exception = new Exception("Error within hook!"); + + // Act + await hook.ErrorAsync(context, exception); + + // Assert + Assert.Equal(1, logger.Collector.Count); + + var record = logger.LatestRecord; + + Assert.Equal(LogLevel.Error, record.Level); + } + + [Fact] + public async Task ErrorAsync_Without_EvaluationContext_Generates_Correct_Log_Message() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + + var hook = new LoggingHook(logger, includeContext: false); + + var exception = new Exception("Error within hook!"); + + // Act + await hook.ErrorAsync(context, exception); + + // Assert + var record = logger.LatestRecord; + + Assert.Equal( + """ + Error during Flag Evaluation Domain:client + ProviderName:provider + FlagKey:test + DefaultValue:False + + """, + record.Message, + ignoreLineEndingDifferences: true + ); + } + + [Fact] + public async Task ErrorAsync_With_EvaluationContext_Generates_Correct_Log_Message() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + + var timestamp = DateTime.Parse("2099-01-01T01:00:00.0000000Z"); + var evaluationContext = EvaluationContext.Builder() + .Set("key_1", " ") + .Set("key_2", true) + .Set("key_3", 0.002154) + .Set("key_4", -15) + .Set("key_5", timestamp) + .Build(); + + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, evaluationContext); + + var hook = new LoggingHook(logger, includeContext: true); + + var exception = new Exception("Error within hook!"); + + // Act + await hook.ErrorAsync(context, exception); + + // Assert + Assert.Equal(1, logger.Collector.Count); + + var record = logger.LatestRecord; + Assert.Equal(LogLevel.Error, record.Level); + + Assert.Multiple( + () => Assert.Contains("key_1: ", record.Message), + () => Assert.Contains("key_2:True", record.Message), + () => Assert.Contains("key_3:0.002154", record.Message), + () => Assert.Contains("key_4:-15", record.Message), + () => Assert.Contains($"key_5:{timestamp:O}", record.Message) + ); + } + + [Fact] + public async Task ErrorAsync_With_No_EvaluationContext_Generates_Correct_Log_Message() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + + var hook = new LoggingHook(logger, includeContext: true); + + var exception = new Exception("Error within hook!"); + + // Act + await hook.ErrorAsync(context, exception); + + // Assert + Assert.Equal(1, logger.Collector.Count); + + var record = logger.LatestRecord; + Assert.Equal(LogLevel.Error, record.Level); + + Assert.Equal( + """ + Error during Flag Evaluation Domain:client + ProviderName:provider + FlagKey:test + DefaultValue:False + Context: + + """, + record.Message, + ignoreLineEndingDifferences: true + ); + } + + [Fact] + public async Task AfterAsync_Without_EvaluationContext_Generates_Debug_Log() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + + var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); + + var hook = new LoggingHook(logger, includeContext: false); + + // Act + await hook.AfterAsync(context, details); + + // Assert + Assert.Equal(1, logger.Collector.Count); + + var record = logger.LatestRecord; + Assert.Equal(LogLevel.Debug, record.Level); + } + + [Fact] + public async Task AfterAsync_Without_EvaluationContext_Generates_Correct_Log_Message() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + + var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); + + var hook = new LoggingHook(logger, includeContext: false); + + // Act + await hook.AfterAsync(context, details); + + // Assert + var record = logger.LatestRecord; + + Assert.Equal( + """ + After Flag Evaluation Domain:client + ProviderName:provider + FlagKey:test + DefaultValue:False + + """, + record.Message, + ignoreLineEndingDifferences: true + ); + } + + [Fact] + public async Task AfterAsync_With_EvaluationContext_Generates_Correct_Log_Message() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var evaluationContext = EvaluationContext.Builder() + .Set("key_1", "") + .Set("key_2", false) + .Set("key_3", double.MinValue) + .Set("key_4", int.MaxValue) + .Set("key_5", DateTime.MinValue) + .Build(); + + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, evaluationContext); + + var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); + + var hook = new LoggingHook(logger, includeContext: true); + + // Act + await hook.AfterAsync(context, details); + + // Assert + Assert.Equal(1, logger.Collector.Count); + + var record = logger.LatestRecord; + Assert.Equal(LogLevel.Debug, record.Level); + + // .NET Framework uses G15 formatter on double.ToString + // .NET uses G17 formatter on double.ToString +#if NET462 + var expectedMaxDoubleString = "-1.79769313486232E+308"; +#else + var expectedMaxDoubleString = "-1.7976931348623157E+308"; +#endif + Assert.Multiple( + () => Assert.Contains("key_1:", record.Message), + () => Assert.Contains("key_2:False", record.Message), + () => Assert.Contains($"key_3:{expectedMaxDoubleString}", record.Message), + () => Assert.Contains("key_4:2147483647", record.Message), + () => Assert.Contains("key_5:0001-01-01T00:00:00.0000000", record.Message) + ); + } + + [Fact] + public async Task AfterAsync_With_No_EvaluationContext_Generates_Correct_Log_Message() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + + var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); + + var hook = new LoggingHook(logger, includeContext: true); + + // Act + await hook.AfterAsync(context, details); + + // Assert + Assert.Equal(1, logger.Collector.Count); + + var record = logger.LatestRecord; + Assert.Equal(LogLevel.Debug, record.Level); + + Assert.Equal( + """ + After Flag Evaluation Domain:client + ProviderName:provider + FlagKey:test + DefaultValue:False + Context: + + """, + record.Message, + ignoreLineEndingDifferences: true + ); + } + + [Fact] + public void Create_LoggingHook_Without_Logger() + { + Assert.Throws(() => new LoggingHook(null!, includeContext: true)); + } + + [Fact] + public async Task With_Structure_Type_In_Context_Returns_Qualified_Name_Of_Value() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var evaluationContext = EvaluationContext.Builder() + .Set("key_1", Structure.Builder().Set("inner_key_1", false).Build()) + .Build(); + + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, evaluationContext); + + var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); + + var hook = new LoggingHook(logger, includeContext: true); + + // Act + await hook.AfterAsync(context, details); + + // Assert + var record = logger.LatestRecord; + + // Raw string literals will convert tab to spaces (the File index style) + var message = NormalizeLogRecord(record); + + Assert.Equal( + """ + After Flag Evaluation Domain:client + ProviderName:provider + FlagKey:test + DefaultValue:False + Context: + key_1:OpenFeature.Model.Value + + """, + message, + ignoreLineEndingDifferences: true + ); + } + + [Fact] + public async Task Without_Domain_Returns_Missing() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata(null, "1.0.0"); + var providerMetadata = new Metadata("provider"); + var evaluationContext = EvaluationContext.Builder() + .Set("key_1", true) + .Build(); + + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, evaluationContext); + + var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); + + var hook = new LoggingHook(logger, includeContext: true); + + // Act + await hook.AfterAsync(context, details); + + // Assert + var record = logger.LatestRecord; + var message = NormalizeLogRecord(record); + + Assert.Equal( + """ + After Flag Evaluation Domain:missing + ProviderName:provider + FlagKey:test + DefaultValue:False + Context: + key_1:True + + """, + message, + ignoreLineEndingDifferences: true + ); + } + + [Fact] + public async Task Without_Provider_Returns_Missing() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata(null); + var evaluationContext = EvaluationContext.Builder() + .Set("key_1", true) + .Build(); + + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, evaluationContext); + + var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); + + var hook = new LoggingHook(logger, includeContext: true); + + // Act + await hook.AfterAsync(context, details); + + // Assert + var record = logger.LatestRecord; + var message = NormalizeLogRecord(record); + + Assert.Equal( + """ + After Flag Evaluation Domain:client + ProviderName:missing + FlagKey:test + DefaultValue:False + Context: + key_1:True + + """, + message, + ignoreLineEndingDifferences: true + ); + } + + [Fact] + public async Task Without_DefaultValue_Returns_Missing() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var evaluationContext = EvaluationContext.Builder() + .Set("key_1", true) + .Build(); + + var context = new HookContext("test", null!, FlagValueType.Object, clientMetadata, + providerMetadata, evaluationContext); + + var details = new FlagEvaluationDetails("test", "true", ErrorType.None, reason: null, variant: null); + + var hook = new LoggingHook(logger, includeContext: true); + + // Act + await hook.AfterAsync(context, details); + + // Assert + var record = logger.LatestRecord; + var message = NormalizeLogRecord(record); + + Assert.Equal( + """ + After Flag Evaluation Domain:client + ProviderName:provider + FlagKey:test + DefaultValue:missing + Context: + key_1:True + + """, + message, + ignoreLineEndingDifferences: true + ); + } + + [Fact] + public async Task Without_EvaluationContextValue_Returns_Nothing() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var evaluationContext = EvaluationContext.Builder() + .Set("key_1", (string)null!) + .Build(); + + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, evaluationContext); + + var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); + + var hook = new LoggingHook(logger, includeContext: true); + + // Act + await hook.AfterAsync(context, details); + + // Assert + var record = logger.LatestRecord; + var message = NormalizeLogRecord(record); + + Assert.Equal( + """ + After Flag Evaluation Domain:client + ProviderName:provider + FlagKey:test + DefaultValue:False + Context: + key_1: + + """, + message, + ignoreLineEndingDifferences: true + ); + } + + private static string NormalizeLogRecord(FakeLogRecord record) + { + // Raw string literals will convert tab to spaces (the File index style) + const int tabSize = 4; + + return record.Message.Replace("\t", new string(' ', tabSize)); + } +} diff --git a/test/OpenFeature.Tests/ImmutableMetadataTest.cs b/test/OpenFeature.Tests/ImmutableMetadataTest.cs new file mode 100644 index 00000000..cd2fd1d8 --- /dev/null +++ b/test/OpenFeature.Tests/ImmutableMetadataTest.cs @@ -0,0 +1,275 @@ +using System.Collections.Generic; +using OpenFeature.Model; +using OpenFeature.Tests.Internal; +using Xunit; + +namespace OpenFeature.Tests; + +public class ImmutableMetadataTest +{ + [Fact] + [Specification("1.4.14", + "If the `flag metadata` field in the `flag resolution` structure returned by the configured `provider` is set, the `evaluation details` structure's `flag metadata` field MUST contain that value. Otherwise, it MUST contain an empty record.")] + public void GetBool_Should_Return_Null_If_Key_Not_Found() + { + // Arrange + var flagMetadata = new ImmutableMetadata(); + + // Act + var result = flagMetadata.GetBool("nonexistentKey"); + + // Assert + Assert.Null(result); + } + + [Fact] + [Specification("1.4.14", + "If the `flag metadata` field in the `flag resolution` structure returned by the configured `provider` is set, the `evaluation details` structure's `flag metadata` field MUST contain that value. Otherwise, it MUST contain an empty record.")] + [Specification("1.4.14.1", "Condition: `Flag metadata` MUST be immutable.")] + public void GetBool_Should_Return_Value_If_Key_Found() + { + // Arrange + var metadata = new Dictionary + { + { + "boolKey", true + } + }; + var flagMetadata = new ImmutableMetadata(metadata); + + // Act + var result = flagMetadata.GetBool("boolKey"); + + // Assert + Assert.True(result); + } + + [Fact] + [Specification("1.4.14", + "If the `flag metadata` field in the `flag resolution` structure returned by the configured `provider` is set, the `evaluation details` structure's `flag metadata` field MUST contain that value. Otherwise, it MUST contain an empty record.")] + public void GetBool_Should_Throw_Value_Is_Invalid() + { + // Arrange + var metadata = new Dictionary + { + { + "wrongKey", "11a" + } + }; + var flagMetadata = new ImmutableMetadata(metadata); + + // Act + var result = flagMetadata.GetBool("wrongKey"); + + // Assert + Assert.Null(result); + } + + [Fact] + [Specification("1.4.14", + "If the `flag metadata` field in the `flag resolution` structure returned by the configured `provider` is set, the `evaluation details` structure's `flag metadata` field MUST contain that value. Otherwise, it MUST contain an empty record.")] + public void GetInt_Should_Return_Null_If_Key_Not_Found() + { + // Arrange + var flagMetadata = new ImmutableMetadata(); + + // Act + var result = flagMetadata.GetInt("nonexistentKey"); + + // Assert + Assert.Null(result); + } + + [Fact] + [Specification("1.4.14", + "If the `flag metadata` field in the `flag resolution` structure returned by the configured `provider` is set, the `evaluation details` structure's `flag metadata` field MUST contain that value. Otherwise, it MUST contain an empty record.")] + [Specification("1.4.14.1", "Condition: `Flag metadata` MUST be immutable.")] + public void GetInt_Should_Return_Value_If_Key_Found() + { + // Arrange + var metadata = new Dictionary + { + { + "intKey", 1 + } + }; + var flagMetadata = new ImmutableMetadata(metadata); + + // Act + var result = flagMetadata.GetInt("intKey"); + + // Assert + Assert.NotNull(result); + Assert.Equal(1, result); + } + + [Fact] + [Specification("1.4.14", + "If the `flag metadata` field in the `flag resolution` structure returned by the configured `provider` is set, the `evaluation details` structure's `flag metadata` field MUST contain that value. Otherwise, it MUST contain an empty record.")] + public void GetInt_Should_Throw_Value_Is_Invalid() + { + // Arrange + var metadata = new Dictionary + { + { + "wrongKey", "11a" + } + }; + var flagMetadata = new ImmutableMetadata(metadata); + + // Act + var result = flagMetadata.GetInt("wrongKey"); + + // Assert + Assert.Null(result); + } + + [Fact] + [Specification("1.4.14", + "If the `flag metadata` field in the `flag resolution` structure returned by the configured `provider` is set, the `evaluation details` structure's `flag metadata` field MUST contain that value. Otherwise, it MUST contain an empty record.")] + public void GetDouble_Should_Return_Null_If_Key_Not_Found() + { + // Arrange + var flagMetadata = new ImmutableMetadata(); + + // Act + var result = flagMetadata.GetDouble("nonexistentKey"); + + // Assert + Assert.Null(result); + } + + [Fact] + [Specification("1.4.14", + "If the `flag metadata` field in the `flag resolution` structure returned by the configured `provider` is set, the `evaluation details` structure's `flag metadata` field MUST contain that value. Otherwise, it MUST contain an empty record.")] + [Specification("1.4.14.1", "Condition: `Flag metadata` MUST be immutable.")] + public void GetDouble_Should_Return_Value_If_Key_Found() + { + // Arrange + var metadata = new Dictionary + { + { + "doubleKey", 1.2 + } + }; + var flagMetadata = new ImmutableMetadata(metadata); + + // Act + var result = flagMetadata.GetDouble("doubleKey"); + + // Assert + Assert.NotNull(result); + Assert.Equal(1.2, result); + } + + [Fact] + [Specification("1.4.14", + "If the `flag metadata` field in the `flag resolution` structure returned by the configured `provider` is set, the `evaluation details` structure's `flag metadata` field MUST contain that value. Otherwise, it MUST contain an empty record.")] + public void GetDouble_Should_Throw_Value_Is_Invalid() + { + // Arrange + var metadata = new Dictionary + { + { + "wrongKey", "11a" + } + }; + var flagMetadata = new ImmutableMetadata(metadata); + + // Act + var result = flagMetadata.GetDouble("wrongKey"); + + // Assert + Assert.Null(result); + } + + [Fact] + [Specification("1.4.14", + "If the `flag metadata` field in the `flag resolution` structure returned by the configured `provider` is set, the `evaluation details` structure's `flag metadata` field MUST contain that value. Otherwise, it MUST contain an empty record.")] + public void GetString_Should_Return_Null_If_Key_Not_Found() + { + // Arrange + var flagMetadata = new ImmutableMetadata(); + + // Act + var result = flagMetadata.GetString("nonexistentKey"); + + // Assert + Assert.Null(result); + } + + [Fact] + [Specification("1.4.14", + "If the `flag metadata` field in the `flag resolution` structure returned by the configured `provider` is set, the `evaluation details` structure's `flag metadata` field MUST contain that value. Otherwise, it MUST contain an empty record.")] + [Specification("1.4.14.1", "Condition: `Flag metadata` MUST be immutable.")] + public void GetString_Should_Return_Value_If_Key_Found() + { + // Arrange + var metadata = new Dictionary + { + { + "stringKey", "11" + } + }; + var flagMetadata = new ImmutableMetadata(metadata); + + // Act + var result = flagMetadata.GetString("stringKey"); + + // Assert + Assert.NotNull(result); + Assert.Equal("11", result); + } + + [Fact] + [Specification("1.4.14", + "If the `flag metadata` field in the `flag resolution` structure returned by the configured `provider` is set, the `evaluation details` structure's `flag metadata` field MUST contain that value. Otherwise, it MUST contain an empty record.")] + public void GetString_Should_Throw_Value_Is_Invalid() + { + // Arrange + var metadata = new Dictionary + { + { + "wrongKey", new object() + } + }; + var flagMetadata = new ImmutableMetadata(metadata); + + // Act + var result = flagMetadata.GetString("wrongKey"); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Count_ShouldReturnCountOfMetadata() + { + // Arrange + var metadata = new Dictionary + { + { + "wrongKey", new object() + }, + { + "stringKey", "11" + }, + { + "doubleKey", 1.2 + }, + { + "intKey", 1 + }, + { + "boolKey", true + } + }; + var flagMetadata = new ImmutableMetadata(metadata); + + // Act + var result = flagMetadata.Count; + + // Assert + Assert.Equal(metadata.Count, result); + } +} diff --git a/test/OpenFeature.Tests/Internal/SpecificationAttribute.cs b/test/OpenFeature.Tests/Internal/SpecificationAttribute.cs index 624c54cc..9fea9fef 100644 --- a/test/OpenFeature.Tests/Internal/SpecificationAttribute.cs +++ b/test/OpenFeature.Tests/Internal/SpecificationAttribute.cs @@ -1,17 +1,16 @@ using System; -namespace OpenFeature.SDK.Tests.Internal +namespace OpenFeature.Tests.Internal; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] +public class SpecificationAttribute : Attribute { - [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] - public class SpecificationAttribute : Attribute - { - public string Code { get; } - public string Description { get; } + public string Code { get; } + public string Description { get; } - public SpecificationAttribute(string code, string description) - { - this.Code = code; - this.Description = description; - } + public SpecificationAttribute(string code, string description) + { + this.Code = code; + this.Description = description; } } diff --git a/test/OpenFeature.Tests/OpenFeature.Tests.csproj b/test/OpenFeature.Tests/OpenFeature.Tests.csproj index 06639880..a556655a 100644 --- a/test/OpenFeature.Tests/OpenFeature.Tests.csproj +++ b/test/OpenFeature.Tests/OpenFeature.Tests.csproj @@ -1,25 +1,26 @@ - +๏ปฟ - net6.0 + net8.0;net9.0 $(TargetFrameworks);net462 + OpenFeature.Tests - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/test/OpenFeature.Tests/OpenFeatureClientTests.cs b/test/OpenFeature.Tests/OpenFeatureClientTests.cs index 9646ae96..a43cf4d8 100644 --- a/test/OpenFeature.Tests/OpenFeatureClientTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureClientTests.cs @@ -1,308 +1,677 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; using System.Threading.Tasks; using AutoFixture; -using FluentAssertions; using Microsoft.Extensions.Logging; -using Moq; -using OpenFeature.SDK.Constant; -using OpenFeature.SDK.Error; -using OpenFeature.SDK.Extension; -using OpenFeature.SDK.Model; -using OpenFeature.SDK.Tests.Internal; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using OpenFeature.Constant; +using OpenFeature.Error; +using OpenFeature.Extension; +using OpenFeature.Model; +using OpenFeature.Tests.Internal; using Xunit; -namespace OpenFeature.SDK.Tests +namespace OpenFeature.Tests; + +public class OpenFeatureClientTests : ClearOpenFeatureInstanceFixture { - public class OpenFeatureClientTests + [Fact] + [Specification("1.2.1", "The client MUST provide a method to add `hooks` which accepts one or more API-conformant `hooks`, and appends them to the collection of any previously added hooks. When new hooks are added, previously added hooks are not removed.")] + public void OpenFeatureClient_Should_Allow_Hooks() { - [Fact] - [Specification("1.2.1", "The client MUST provide a method to add `hooks` which accepts one or more API-conformant `hooks`, and appends them to the collection of any previously added hooks. When new hooks are added, previously added hooks are not removed.")] - public void OpenFeatureClient_Should_Allow_Hooks() - { - var fixture = new Fixture(); - var clientName = fixture.Create(); - var hook1 = new Mock().Object; - var hook2 = new Mock().Object; - var hook3 = new Mock().Object; + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); + var hook3 = Substitute.For(); - var client = OpenFeature.Instance.GetClient(clientName); + var client = Api.Instance.GetClient(domain, clientVersion); - client.AddHooks(new[] { hook1, hook2 }); + client.AddHooks(new[] { hook1, hook2 }); - client.GetHooks().Should().ContainInOrder(hook1, hook2); - client.GetHooks().Count.Should().Be(2); + var expectedHooks = new[] { hook1, hook2 }.AsEnumerable(); + Assert.Equal(expectedHooks, client.GetHooks()); - client.AddHooks(hook3); - client.GetHooks().Should().ContainInOrder(hook1, hook2, hook3); - client.GetHooks().Count.Should().Be(3); + client.AddHooks(hook3); - client.ClearHooks(); - client.GetHooks().Count.Should().Be(0); - } + expectedHooks = new[] { hook1, hook2, hook3 }.AsEnumerable(); + Assert.Equal(expectedHooks, client.GetHooks()); - [Fact] - [Specification("1.2.2", "The client interface MUST define a `metadata` member or accessor, containing an immutable `name` field or accessor of type string, which corresponds to the `name` value supplied during client creation.")] - public void OpenFeatureClient_Metadata_Should_Have_Name() - { - var fixture = new Fixture(); - var clientName = fixture.Create(); - var clientVersion = fixture.Create(); - var client = OpenFeature.Instance.GetClient(clientName, clientVersion); + client.ClearHooks(); + Assert.Empty(client.GetHooks()); + } - client.GetMetadata().Name.Should().Be(clientName); - client.GetMetadata().Version.Should().Be(clientVersion); - } + [Fact] + [Specification("1.2.2", "The client interface MUST define a `metadata` member or accessor, containing an immutable `name` field or accessor of type string, which corresponds to the `name` value supplied during client creation.")] + public void OpenFeatureClient_Metadata_Should_Have_Name() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var client = Api.Instance.GetClient(domain, clientVersion); - [Fact] - [Specification("1.3.1", "The `client` MUST provide methods for flag evaluation, with parameters `flag key` (string, required), `default value` (boolean | number | string | structure, required), `evaluation context` (optional), and `evaluation options` (optional), which returns the flag value.")] - [Specification("1.3.2.1", "The `client` MUST provide methods for typed flag evaluation, including boolean, numeric, string, and structure.")] - public async Task OpenFeatureClient_Should_Allow_Flag_Evaluation() - { - var fixture = new Fixture(); - var clientName = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultBoolValue = fixture.Create(); - var defaultStringValue = fixture.Create(); - var defaultNumberValue = fixture.Create(); - var defaultStructureValue = fixture.Create(); - var emptyFlagOptions = new FlagEvaluationOptions(new List(), new Dictionary()); - - OpenFeature.Instance.SetProvider(new NoOpFeatureProvider()); - var client = OpenFeature.Instance.GetClient(clientName, clientVersion); - - (await client.GetBooleanValue(flagName, defaultBoolValue)).Should().Be(defaultBoolValue); - (await client.GetBooleanValue(flagName, defaultBoolValue, new EvaluationContext())).Should().Be(defaultBoolValue); - (await client.GetBooleanValue(flagName, defaultBoolValue, new EvaluationContext(), emptyFlagOptions)).Should().Be(defaultBoolValue); - - (await client.GetNumberValue(flagName, defaultNumberValue)).Should().Be(defaultNumberValue); - (await client.GetNumberValue(flagName, defaultNumberValue, new EvaluationContext())).Should().Be(defaultNumberValue); - (await client.GetNumberValue(flagName, defaultNumberValue, new EvaluationContext(), emptyFlagOptions)).Should().Be(defaultNumberValue); - - (await client.GetStringValue(flagName, defaultStringValue)).Should().Be(defaultStringValue); - (await client.GetStringValue(flagName, defaultStringValue, new EvaluationContext())).Should().Be(defaultStringValue); - (await client.GetStringValue(flagName, defaultStringValue, new EvaluationContext(), emptyFlagOptions)).Should().Be(defaultStringValue); - - (await client.GetObjectValue(flagName, defaultStructureValue)).Should().BeEquivalentTo(defaultStructureValue); - (await client.GetObjectValue(flagName, defaultStructureValue, new EvaluationContext())).Should().BeEquivalentTo(defaultStructureValue); - (await client.GetObjectValue(flagName, defaultStructureValue, new EvaluationContext(), emptyFlagOptions)).Should().BeEquivalentTo(defaultStructureValue); - } + Assert.Equal(domain, client.GetMetadata().Name); + Assert.Equal(clientVersion, client.GetMetadata().Version); + } - [Fact] - [Specification("1.4.1", "The `client` MUST provide methods for detailed flag value evaluation with parameters `flag key` (string, required), `default value` (boolean | number | string | structure, required), `evaluation context` (optional), and `evaluation options` (optional), which returns an `evaluation details` structure.")] - [Specification("1.4.2", "The `evaluation details` structure's `value` field MUST contain the evaluated flag value.")] - [Specification("1.4.3.1", "The `evaluation details` structure SHOULD accept a generic argument (or use an equivalent language feature) which indicates the type of the wrapped `value` field.")] - [Specification("1.4.4", "The `evaluation details` structure's `flag key` field MUST contain the `flag key` argument passed to the detailed flag evaluation method.")] - [Specification("1.4.5", "In cases of normal execution, the `evaluation details` structure's `variant` field MUST contain the value of the `variant` field in the `flag resolution` structure returned by the configured `provider`, if the field is set.")] - [Specification("1.4.6", "In cases of normal execution, the `evaluation details` structure's `reason` field MUST contain the value of the `reason` field in the `flag resolution` structure returned by the configured `provider`, if the field is set.")] - [Specification("1.4.11", "The `client` SHOULD provide asynchronous or non-blocking mechanisms for flag evaluation.")] - [Specification("2.9", "The `flag resolution` structure SHOULD accept a generic argument (or use an equivalent language feature) which indicates the type of the wrapped `value` field.")] - public async Task OpenFeatureClient_Should_Allow_Details_Flag_Evaluation() - { - var fixture = new Fixture(); - var clientName = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultBoolValue = fixture.Create(); - var defaultStringValue = fixture.Create(); - var defaultNumberValue = fixture.Create(); - var defaultStructureValue = fixture.Create(); - var emptyFlagOptions = new FlagEvaluationOptions(new List(), new Dictionary()); - - OpenFeature.Instance.SetProvider(new NoOpFeatureProvider()); - var client = OpenFeature.Instance.GetClient(clientName, clientVersion); - - var boolFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultBoolValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); - (await client.GetBooleanDetails(flagName, defaultBoolValue)).Should().BeEquivalentTo(boolFlagEvaluationDetails); - (await client.GetBooleanDetails(flagName, defaultBoolValue, new EvaluationContext())).Should().BeEquivalentTo(boolFlagEvaluationDetails); - (await client.GetBooleanDetails(flagName, defaultBoolValue, new EvaluationContext(), emptyFlagOptions)).Should().BeEquivalentTo(boolFlagEvaluationDetails); - - var numberFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultNumberValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); - (await client.GetNumberDetails(flagName, defaultNumberValue)).Should().BeEquivalentTo(numberFlagEvaluationDetails); - (await client.GetNumberDetails(flagName, defaultNumberValue, new EvaluationContext())).Should().BeEquivalentTo(numberFlagEvaluationDetails); - (await client.GetNumberDetails(flagName, defaultNumberValue, new EvaluationContext(), emptyFlagOptions)).Should().BeEquivalentTo(numberFlagEvaluationDetails); - - var stringFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultStringValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); - (await client.GetStringDetails(flagName, defaultStringValue)).Should().BeEquivalentTo(stringFlagEvaluationDetails); - (await client.GetStringDetails(flagName, defaultStringValue, new EvaluationContext())).Should().BeEquivalentTo(stringFlagEvaluationDetails); - (await client.GetStringDetails(flagName, defaultStringValue, new EvaluationContext(), emptyFlagOptions)).Should().BeEquivalentTo(stringFlagEvaluationDetails); - - var structureFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultStructureValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); - (await client.GetObjectDetails(flagName, defaultStructureValue)).Should().BeEquivalentTo(structureFlagEvaluationDetails); - (await client.GetObjectDetails(flagName, defaultStructureValue, new EvaluationContext())).Should().BeEquivalentTo(structureFlagEvaluationDetails); - (await client.GetObjectDetails(flagName, defaultStructureValue, new EvaluationContext(), emptyFlagOptions)).Should().BeEquivalentTo(structureFlagEvaluationDetails); - } + [Fact] + [Specification("1.3.1", "The `client` MUST provide methods for typed flag evaluation, including boolean, numeric, string, and structure, with parameters `flag key` (string, required), `default value` (boolean | number | string | structure, required), `evaluation context` (optional), and `evaluation options` (optional), which returns the flag value.")] + [Specification("1.3.2.1", "The client SHOULD provide functions for floating-point numbers and integers, consistent with language idioms.")] + [Specification("1.3.3", "The `client` SHOULD guarantee the returned value of any typed flag evaluation method is of the expected type. If the value returned by the underlying provider implementation does not match the expected type, it's to be considered abnormal execution, and the supplied `default value` should be returned.")] + public async Task OpenFeatureClient_Should_Allow_Flag_Evaluation() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultBoolValue = fixture.Create(); + var defaultStringValue = fixture.Create(); + var defaultIntegerValue = fixture.Create(); + var defaultDoubleValue = fixture.Create(); + var defaultStructureValue = fixture.Create(); + var emptyFlagOptions = new FlagEvaluationOptions(ImmutableList.Empty, ImmutableDictionary.Empty); + + await Api.Instance.SetProviderAsync(new NoOpFeatureProvider()); + var client = Api.Instance.GetClient(domain, clientVersion); + + Assert.Equal(defaultBoolValue, await client.GetBooleanValueAsync(flagName, defaultBoolValue)); + Assert.Equal(defaultBoolValue, await client.GetBooleanValueAsync(flagName, defaultBoolValue, EvaluationContext.Empty)); + Assert.Equal(defaultBoolValue, await client.GetBooleanValueAsync(flagName, defaultBoolValue, EvaluationContext.Empty, emptyFlagOptions)); + + Assert.Equal(defaultIntegerValue, await client.GetIntegerValueAsync(flagName, defaultIntegerValue)); + Assert.Equal(defaultIntegerValue, await client.GetIntegerValueAsync(flagName, defaultIntegerValue, EvaluationContext.Empty)); + Assert.Equal(defaultIntegerValue, await client.GetIntegerValueAsync(flagName, defaultIntegerValue, EvaluationContext.Empty, emptyFlagOptions)); + + Assert.Equal(defaultDoubleValue, await client.GetDoubleValueAsync(flagName, defaultDoubleValue)); + Assert.Equal(defaultDoubleValue, await client.GetDoubleValueAsync(flagName, defaultDoubleValue, EvaluationContext.Empty)); + Assert.Equal(defaultDoubleValue, await client.GetDoubleValueAsync(flagName, defaultDoubleValue, EvaluationContext.Empty, emptyFlagOptions)); + + Assert.Equal(defaultStringValue, await client.GetStringValueAsync(flagName, defaultStringValue)); + Assert.Equal(defaultStringValue, await client.GetStringValueAsync(flagName, defaultStringValue, EvaluationContext.Empty)); + Assert.Equal(defaultStringValue, await client.GetStringValueAsync(flagName, defaultStringValue, EvaluationContext.Empty, emptyFlagOptions)); + + Assert.Equivalent(defaultStructureValue, await client.GetObjectValueAsync(flagName, defaultStructureValue)); + Assert.Equivalent(defaultStructureValue, await client.GetObjectValueAsync(flagName, defaultStructureValue, EvaluationContext.Empty)); + Assert.Equivalent(defaultStructureValue, await client.GetObjectValueAsync(flagName, defaultStructureValue, EvaluationContext.Empty, emptyFlagOptions)); + } - [Fact] - [Specification("1.1.2", "The API MUST provide a function to set the global provider singleton, which accepts an API-conformant provider implementation.")] - [Specification("1.3.3", "The `client` SHOULD guarantee the returned value of any typed flag evaluation method is of the expected type. If the value returned by the underlying provider implementation does not match the expected type, it's to be considered abnormal execution, and the supplied `default value` should be returned.")] - [Specification("1.4.7", "In cases of abnormal execution, the `evaluation details` structure's `error code` field MUST contain a string identifying an error occurred during flag evaluation and the nature of the error.")] - [Specification("1.4.8", "In cases of abnormal execution (network failure, unhandled error, etc) the `reason` field in the `evaluation details` SHOULD indicate an error.")] - [Specification("1.4.9", "Methods, functions, or operations on the client MUST NOT throw exceptions, or otherwise abnormally terminate. Flag evaluation calls must always return the `default value` in the event of abnormal execution. Exceptions include functions or methods for the purposes for configuration or setup.")] - [Specification("1.4.10", "In the case of abnormal execution, the client SHOULD log an informative error message.")] - public async Task OpenFeatureClient_Should_Return_DefaultValue_When_Type_Mismatch() - { - var fixture = new Fixture(); - var clientName = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultValue = fixture.Create(); - var mockedFeatureProvider = new Mock(); - var mockedLogger = new Mock>(); - - // This will fail to case a String to TestStructure - mockedFeatureProvider - .Setup(x => x.ResolveStructureValue(flagName, defaultValue, It.IsAny(), null)) - .ReturnsAsync(new ResolutionDetails(flagName, "Mismatch")); - mockedFeatureProvider.Setup(x => x.GetMetadata()) - .Returns(new Metadata(fixture.Create())); - - OpenFeature.Instance.SetProvider(mockedFeatureProvider.Object); - var client = OpenFeature.Instance.GetClient(clientName, clientVersion, mockedLogger.Object); - - var evaluationDetails = await client.GetObjectDetails(flagName, defaultValue); - evaluationDetails.ErrorType.Should().Be(ErrorType.TypeMismatch.GetDescription()); - - mockedFeatureProvider - .Verify(x => x.ResolveStructureValue(flagName, defaultValue, It.IsAny(), null), Times.Once); - - mockedLogger.Verify( - x => x.Log( - LogLevel.Error, - It.IsAny(), - It.Is((o, t) => string.Equals($"Error while evaluating flag {flagName}", o.ToString(), StringComparison.InvariantCultureIgnoreCase)), - It.IsAny(), - It.IsAny>()), - Times.Once); - } + [Fact] + [Specification("1.4.1", "The `client` MUST provide methods for detailed flag value evaluation with parameters `flag key` (string, required), `default value` (boolean | number | string | structure, required), `evaluation context` (optional), and `evaluation options` (optional), which returns an `evaluation details` structure.")] + [Specification("1.4.2", "The `evaluation details` structure's `value` field MUST contain the evaluated flag value.")] + [Specification("1.4.3.1", "The `evaluation details` structure SHOULD accept a generic argument (or use an equivalent language feature) which indicates the type of the wrapped `value` field.")] + [Specification("1.4.4", "The `evaluation details` structure's `flag key` field MUST contain the `flag key` argument passed to the detailed flag evaluation method.")] + [Specification("1.4.5", "In cases of normal execution, the `evaluation details` structure's `variant` field MUST contain the value of the `variant` field in the `flag resolution` structure returned by the configured `provider`, if the field is set.")] + [Specification("1.4.6", "In cases of normal execution, the `evaluation details` structure's `reason` field MUST contain the value of the `reason` field in the `flag resolution` structure returned by the configured `provider`, if the field is set.")] + [Specification("1.4.11", "The `client` SHOULD provide asynchronous or non-blocking mechanisms for flag evaluation.")] + [Specification("2.2.8.1", "The `resolution details` structure SHOULD accept a generic argument (or use an equivalent language feature) which indicates the type of the wrapped `value` field.")] + public async Task OpenFeatureClient_Should_Allow_Details_Flag_Evaluation() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultBoolValue = fixture.Create(); + var defaultStringValue = fixture.Create(); + var defaultIntegerValue = fixture.Create(); + var defaultDoubleValue = fixture.Create(); + var defaultStructureValue = fixture.Create(); + var emptyFlagOptions = new FlagEvaluationOptions(ImmutableList.Empty, ImmutableDictionary.Empty); + + await Api.Instance.SetProviderAsync(new NoOpFeatureProvider()); + var client = Api.Instance.GetClient(domain, clientVersion); + + var boolFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultBoolValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + Assert.Equivalent(boolFlagEvaluationDetails, await client.GetBooleanDetailsAsync(flagName, defaultBoolValue)); + Assert.Equivalent(boolFlagEvaluationDetails, await client.GetBooleanDetailsAsync(flagName, defaultBoolValue, EvaluationContext.Empty)); + Assert.Equivalent(boolFlagEvaluationDetails, await client.GetBooleanDetailsAsync(flagName, defaultBoolValue, EvaluationContext.Empty, emptyFlagOptions)); + + var integerFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultIntegerValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + Assert.Equivalent(integerFlagEvaluationDetails, await client.GetIntegerDetailsAsync(flagName, defaultIntegerValue)); + Assert.Equivalent(integerFlagEvaluationDetails, await client.GetIntegerDetailsAsync(flagName, defaultIntegerValue, EvaluationContext.Empty)); + Assert.Equivalent(integerFlagEvaluationDetails, await client.GetIntegerDetailsAsync(flagName, defaultIntegerValue, EvaluationContext.Empty, emptyFlagOptions)); + + var doubleFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultDoubleValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + Assert.Equivalent(doubleFlagEvaluationDetails, await client.GetDoubleDetailsAsync(flagName, defaultDoubleValue)); + Assert.Equivalent(doubleFlagEvaluationDetails, await client.GetDoubleDetailsAsync(flagName, defaultDoubleValue, EvaluationContext.Empty)); + Assert.Equivalent(doubleFlagEvaluationDetails, await client.GetDoubleDetailsAsync(flagName, defaultDoubleValue, EvaluationContext.Empty, emptyFlagOptions)); + + var stringFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultStringValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + Assert.Equivalent(stringFlagEvaluationDetails, await client.GetStringDetailsAsync(flagName, defaultStringValue)); + Assert.Equivalent(stringFlagEvaluationDetails, await client.GetStringDetailsAsync(flagName, defaultStringValue, EvaluationContext.Empty)); + Assert.Equivalent(stringFlagEvaluationDetails, await client.GetStringDetailsAsync(flagName, defaultStringValue, EvaluationContext.Empty, emptyFlagOptions)); + + var structureFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultStructureValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + Assert.Equivalent(structureFlagEvaluationDetails, await client.GetObjectDetailsAsync(flagName, defaultStructureValue)); + Assert.Equivalent(structureFlagEvaluationDetails, await client.GetObjectDetailsAsync(flagName, defaultStructureValue, EvaluationContext.Empty)); + Assert.Equivalent(structureFlagEvaluationDetails, await client.GetObjectDetailsAsync(flagName, defaultStructureValue, EvaluationContext.Empty, emptyFlagOptions)); + } - [Fact] - public async Task Should_Resolve_BooleanValue() - { - var fixture = new Fixture(); - var clientName = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultValue = fixture.Create(); + [Fact] + [Specification("1.1.2", "The `API` MUST provide a function to set the default `provider`, which accepts an API-conformant `provider` implementation.")] + [Specification("1.3.3", "The `client` SHOULD guarantee the returned value of any typed flag evaluation method is of the expected type. If the value returned by the underlying provider implementation does not match the expected type, it's to be considered abnormal execution, and the supplied `default value` should be returned.")] + [Specification("1.4.7", "In cases of abnormal execution, the `evaluation details` structure's `error code` field MUST contain an `error code`.")] + [Specification("1.4.8", "In cases of abnormal execution (network failure, unhandled error, etc) the `reason` field in the `evaluation details` SHOULD indicate an error.")] + [Specification("1.4.9", "Methods, functions, or operations on the client MUST NOT throw exceptions, or otherwise abnormally terminate. Flag evaluation calls must always return the `default value` in the event of abnormal execution. Exceptions include functions or methods for the purposes for configuration or setup.")] + [Specification("1.4.10", "In the case of abnormal execution, the client SHOULD log an informative error message.")] + public async Task OpenFeatureClient_Should_Return_DefaultValue_When_Type_Mismatch() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultValue = fixture.Create(); + var mockedFeatureProvider = Substitute.For(); + var mockedLogger = Substitute.For>(); - var featureProviderMock = new Mock(); - featureProviderMock - .Setup(x => x.ResolveBooleanValue(flagName, defaultValue, It.IsAny(), null)) - .ReturnsAsync(new ResolutionDetails(flagName, defaultValue)); - featureProviderMock.Setup(x => x.GetMetadata()) - .Returns(new Metadata(fixture.Create())); + // This will fail to case a String to TestStructure + mockedFeatureProvider.ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()).Throws(); + mockedFeatureProvider.GetMetadata().Returns(new Metadata(fixture.Create())); + mockedFeatureProvider.GetProviderHooks().Returns(ImmutableList.Empty); - OpenFeature.Instance.SetProvider(featureProviderMock.Object); - var client = OpenFeature.Instance.GetClient(clientName, clientVersion); + await Api.Instance.SetProviderAsync(mockedFeatureProvider); + var client = Api.Instance.GetClient(domain, clientVersion, mockedLogger); - (await client.GetBooleanValue(flagName, defaultValue)).Should().Be(defaultValue); + var evaluationDetails = await client.GetObjectDetailsAsync(flagName, defaultValue); + Assert.Equal(ErrorType.TypeMismatch, evaluationDetails.ErrorType); + Assert.Equal(new InvalidCastException().Message, evaluationDetails.ErrorMessage); - featureProviderMock.Verify(x => x.ResolveBooleanValue(flagName, defaultValue, It.IsAny(), null), Times.Once); - } + _ = mockedFeatureProvider.Received(1).ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()); - [Fact] - public async Task Should_Resolve_StringValue() - { - var fixture = new Fixture(); - var clientName = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultValue = fixture.Create(); + mockedLogger.Received(0).IsEnabled(LogLevel.Error); + } + + [Fact] + [Specification("1.7.3", "The client's provider status accessor MUST indicate READY if the initialize function of the associated provider terminates normally.")] + [Specification("1.7.1", "The client MUST define a provider status accessor which indicates the readiness of the associated provider, with possible values NOT_READY, READY, STALE, ERROR, or FATAL.")] + public async Task Provider_Status_Should_Be_Ready_If_Init_Succeeds() + { + var name = "1.7.3"; + // provider which succeeds initialization + var provider = new TestProvider(); + FeatureClient client = Api.Instance.GetClient(name); + Assert.Equal(ProviderStatus.NotReady, provider.Status); + await Api.Instance.SetProviderAsync(name, provider); + + // after init fails fatally, status should be READY + Assert.Equal(ProviderStatus.Ready, client.ProviderStatus); + } + + [Fact] + [Specification("1.7.4", "The client's provider status accessor MUST indicate ERROR if the initialize function of the associated provider terminates abnormally.")] + [Specification("1.7.1", "The client MUST define a provider status accessor which indicates the readiness of the associated provider, with possible values NOT_READY, READY, STALE, ERROR, or FATAL.")] + public async Task Provider_Status_Should_Be_Error_If_Init_Fails() + { + var name = "1.7.4"; + // provider which fails initialization + var provider = new TestProvider("some-name", new GeneralException("fake")); + FeatureClient client = Api.Instance.GetClient(name); + Assert.Equal(ProviderStatus.NotReady, provider.Status); + await Api.Instance.SetProviderAsync(name, provider); + + // after init fails fatally, status should be ERROR + Assert.Equal(ProviderStatus.Error, client.ProviderStatus); + } - var featureProviderMock = new Mock(); - featureProviderMock - .Setup(x => x.ResolveStringValue(flagName, defaultValue, It.IsAny(), null)) - .ReturnsAsync(new ResolutionDetails(flagName, defaultValue)); - featureProviderMock.Setup(x => x.GetMetadata()) - .Returns(new Metadata(fixture.Create())); + [Fact] + [Specification("1.7.5", "The client's provider status accessor MUST indicate FATAL if the initialize function of the associated provider terminates abnormally and indicates error code PROVIDER_FATAL.")] + [Specification("1.7.1", "The client MUST define a provider status accessor which indicates the readiness of the associated provider, with possible values NOT_READY, READY, STALE, ERROR, or FATAL.")] + public async Task Provider_Status_Should_Be_Fatal_If_Init_Fatal() + { + var name = "1.7.5"; + // provider which fails initialization fatally + var provider = new TestProvider(name, new ProviderFatalException("fatal")); + FeatureClient client = Api.Instance.GetClient(name); + Assert.Equal(ProviderStatus.NotReady, provider.Status); + await Api.Instance.SetProviderAsync(name, provider); + + // after init fails fatally, status should be FATAL + Assert.Equal(ProviderStatus.Fatal, client.ProviderStatus); + } - OpenFeature.Instance.SetProvider(featureProviderMock.Object); - var client = OpenFeature.Instance.GetClient(clientName, clientVersion); + [Fact] + [Specification("1.7.6", "The client MUST default, run error hooks, and indicate an error if flag resolution is attempted while the provider is in NOT_READY.")] + public async Task Must_Short_Circuit_Not_Ready() + { + var name = "1.7.6"; + var defaultStr = "123-default"; + + // provider which is never ready (ready after maxValue) + var provider = new TestProvider(name, null, int.MaxValue); + FeatureClient client = Api.Instance.GetClient(name); + Assert.Equal(ProviderStatus.NotReady, provider.Status); + _ = Api.Instance.SetProviderAsync(name, provider); + + var details = await client.GetStringDetailsAsync("some-flag", defaultStr); + Assert.Equal(defaultStr, details.Value); + Assert.Equal(ErrorType.ProviderNotReady, details.ErrorType); + Assert.Equal(Reason.Error, details.Reason); + } - (await client.GetStringValue(flagName, defaultValue)).Should().Be(defaultValue); + [Fact] + [Specification("1.7.7", "The client MUST default, run error hooks, and indicate an error if flag resolution is attempted while the provider is in NOT_READY.")] + public async Task Must_Short_Circuit_Fatal() + { + var name = "1.7.6"; + var defaultStr = "456-default"; + + // provider which immediately fails fatally + var provider = new TestProvider(name, new ProviderFatalException("fake")); + FeatureClient client = Api.Instance.GetClient(name); + Assert.Equal(ProviderStatus.NotReady, provider.Status); + _ = Api.Instance.SetProviderAsync(name, provider); + + var details = await client.GetStringDetailsAsync("some-flag", defaultStr); + Assert.Equal(defaultStr, details.Value); + Assert.Equal(ErrorType.ProviderFatal, details.ErrorType); + Assert.Equal(Reason.Error, details.Reason); + } - featureProviderMock.Verify(x => x.ResolveStringValue(flagName, defaultValue, It.IsAny(), null), Times.Once); - } + [Fact] + public async Task Should_Resolve_BooleanValue() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultValue = fixture.Create(); - [Fact] - public async Task Should_Resolve_NumberValue() - { - var fixture = new Fixture(); - var clientName = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultValue = fixture.Create(); + var featureProviderMock = Substitute.For(); + featureProviderMock.ResolveBooleanValueAsync(flagName, defaultValue, Arg.Any()).Returns(new ResolutionDetails(flagName, defaultValue)); + featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); + featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); - var featureProviderMock = new Mock(); - featureProviderMock - .Setup(x => x.ResolveNumberValue(flagName, defaultValue, It.IsAny(), null)) - .ReturnsAsync(new ResolutionDetails(flagName, defaultValue)); - featureProviderMock.Setup(x => x.GetMetadata()) - .Returns(new Metadata(fixture.Create())); + await Api.Instance.SetProviderAsync(featureProviderMock); + var client = Api.Instance.GetClient(domain, clientVersion); - OpenFeature.Instance.SetProvider(featureProviderMock.Object); - var client = OpenFeature.Instance.GetClient(clientName, clientVersion); + Assert.Equal(defaultValue, await client.GetBooleanValueAsync(flagName, defaultValue)); - (await client.GetNumberValue(flagName, defaultValue)).Should().Be(defaultValue); + _ = featureProviderMock.Received(1).ResolveBooleanValueAsync(flagName, defaultValue, Arg.Any()); + } - featureProviderMock.Verify(x => x.ResolveNumberValue(flagName, defaultValue, It.IsAny(), null), Times.Once); - } + [Fact] + public async Task Should_Resolve_StringValue() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultValue = fixture.Create(); - [Fact] - public async Task Should_Resolve_StructureValue() - { - var fixture = new Fixture(); - var clientName = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultValue = fixture.Create(); + var featureProviderMock = Substitute.For(); + featureProviderMock.ResolveStringValueAsync(flagName, defaultValue, Arg.Any()).Returns(new ResolutionDetails(flagName, defaultValue)); + featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); + featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); - var featureProviderMock = new Mock(); - featureProviderMock - .Setup(x => x.ResolveStructureValue(flagName, defaultValue, It.IsAny(), null)) - .ReturnsAsync(new ResolutionDetails(flagName, defaultValue)); - featureProviderMock.Setup(x => x.GetMetadata()) - .Returns(new Metadata(fixture.Create())); + await Api.Instance.SetProviderAsync(featureProviderMock); + var client = Api.Instance.GetClient(domain, clientVersion); - OpenFeature.Instance.SetProvider(featureProviderMock.Object); - var client = OpenFeature.Instance.GetClient(clientName, clientVersion); + Assert.Equal(defaultValue, await client.GetStringValueAsync(flagName, defaultValue)); - (await client.GetObjectValue(flagName, defaultValue)).Should().Be(defaultValue); + _ = featureProviderMock.Received(1).ResolveStringValueAsync(flagName, defaultValue, Arg.Any()); + } - featureProviderMock.Verify(x => x.ResolveStructureValue(flagName, defaultValue, It.IsAny(), null), Times.Once); - } + [Fact] + public async Task Should_Resolve_IntegerValue() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultValue = fixture.Create(); + + var featureProviderMock = Substitute.For(); + featureProviderMock.ResolveIntegerValueAsync(flagName, defaultValue, Arg.Any()).Returns(new ResolutionDetails(flagName, defaultValue)); + featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); + featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); + + await Api.Instance.SetProviderAsync(featureProviderMock); + var client = Api.Instance.GetClient(domain, clientVersion); + + Assert.Equal(defaultValue, await client.GetIntegerValueAsync(flagName, defaultValue)); + + _ = featureProviderMock.Received(1).ResolveIntegerValueAsync(flagName, defaultValue, Arg.Any()); + } + + [Fact] + public async Task Should_Resolve_DoubleValue() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultValue = fixture.Create(); + + var featureProviderMock = Substitute.For(); + featureProviderMock.ResolveDoubleValueAsync(flagName, defaultValue, Arg.Any()).Returns(new ResolutionDetails(flagName, defaultValue)); + featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); + featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); - [Fact] - public async Task When_Exception_Occurs_During_Evaluation_Should_Return_Error() + await Api.Instance.SetProviderAsync(featureProviderMock); + var client = Api.Instance.GetClient(domain, clientVersion); + + Assert.Equal(defaultValue, await client.GetDoubleValueAsync(flagName, defaultValue)); + + _ = featureProviderMock.Received(1).ResolveDoubleValueAsync(flagName, defaultValue, Arg.Any()); + } + + [Fact] + public async Task Should_Resolve_StructureValue() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultValue = fixture.Create(); + + var featureProviderMock = Substitute.For(); + featureProviderMock.ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()).Returns(new ResolutionDetails(flagName, defaultValue)); + featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); + featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); + + await Api.Instance.SetProviderAsync(featureProviderMock); + var client = Api.Instance.GetClient(domain, clientVersion); + + Assert.Equal(defaultValue, await client.GetObjectValueAsync(flagName, defaultValue)); + + _ = featureProviderMock.Received(1).ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()); + } + + [Fact] + public async Task When_Error_Is_Returned_From_Provider_Should_Return_Error() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultValue = fixture.Create(); + const string testMessage = "Couldn't parse flag data."; + + var featureProviderMock = Substitute.For(); + featureProviderMock.ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()).Returns(Task.FromResult(new ResolutionDetails(flagName, defaultValue, ErrorType.ParseError, "ERROR", null, testMessage))); + featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); + featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); + + await Api.Instance.SetProviderAsync(featureProviderMock); + var client = Api.Instance.GetClient(domain, clientVersion); + var response = await client.GetObjectDetailsAsync(flagName, defaultValue); + + Assert.Equal(ErrorType.ParseError, response.ErrorType); + Assert.Equal(Reason.Error, response.Reason); + Assert.Equal(testMessage, response.ErrorMessage); + _ = featureProviderMock.Received(1).ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()); + } + + [Fact] + public async Task When_Exception_Occurs_During_Evaluation_Should_Return_Error() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultValue = fixture.Create(); + const string testMessage = "Couldn't parse flag data."; + + var featureProviderMock = Substitute.For(); + featureProviderMock.ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()).Throws(new FeatureProviderException(ErrorType.ParseError, testMessage)); + featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); + featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); + + await Api.Instance.SetProviderAsync(featureProviderMock); + var client = Api.Instance.GetClient(domain, clientVersion); + var response = await client.GetObjectDetailsAsync(flagName, defaultValue); + + Assert.Equal(ErrorType.ParseError, response.ErrorType); + Assert.Equal(Reason.Error, response.Reason); + Assert.Equal(testMessage, response.ErrorMessage); + _ = featureProviderMock.Received(1).ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()); + } + + [Fact] + public async Task When_Error_Is_Returned_From_Provider_Should_Not_Run_After_Hook_But_Error_Hook() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultValue = fixture.Create(); + const string testMessage = "Couldn't parse flag data."; + + var featureProviderMock = Substitute.For(); + featureProviderMock.ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()) + .Returns(Task.FromResult(new ResolutionDetails(flagName, defaultValue, ErrorType.ParseError, + "ERROR", null, testMessage))); + featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); + featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); + + await Api.Instance.SetProviderAsync(featureProviderMock); + var client = Api.Instance.GetClient(domain, clientVersion); + var testHook = new TestHook(); + client.AddHooks(testHook); + var response = await client.GetObjectDetailsAsync(flagName, defaultValue); + + Assert.Equal(ErrorType.ParseError, response.ErrorType); + Assert.Equal(Reason.Error, response.Reason); + Assert.Equal(testMessage, response.ErrorMessage); + _ = featureProviderMock.Received(1) + .ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()); + + Assert.Equal(1, testHook.BeforeCallCount); + Assert.Equal(0, testHook.AfterCallCount); + Assert.Equal(1, testHook.ErrorCallCount); + Assert.Equal(1, testHook.FinallyCallCount); + } + + [Fact] + public async Task Cancellation_Token_Added_Is_Passed_To_Provider() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultString = fixture.Create(); + var cancelledReason = "cancelled"; + + var cts = new CancellationTokenSource(); + + + var featureProviderMock = Substitute.For(); + featureProviderMock.ResolveStringValueAsync(flagName, defaultString, Arg.Any(), Arg.Any()).Returns(async args => { - var fixture = new Fixture(); - var clientName = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultValue = fixture.Create(); - - var featureProviderMock = new Mock(); - featureProviderMock - .Setup(x => x.ResolveStructureValue(flagName, defaultValue, It.IsAny(), null)) - .Throws(new FeatureProviderException(ErrorType.ParseError)); - featureProviderMock.Setup(x => x.GetMetadata()) - .Returns(new Metadata(fixture.Create())); - - OpenFeature.Instance.SetProvider(featureProviderMock.Object); - var client = OpenFeature.Instance.GetClient(clientName, clientVersion); - var response = await client.GetObjectDetails(flagName, defaultValue); - - response.ErrorType.Should().Be(ErrorType.ParseError.GetDescription()); - response.Reason.Should().Be(Reason.Error); - featureProviderMock.Verify(x => x.ResolveStructureValue(flagName, defaultValue, It.IsAny(), null), Times.Once); - } + var token = args.ArgAt(3); + while (!token.IsCancellationRequested) + { + await Task.Delay(10); // artificially delay until cancelled + } + + return new ResolutionDetails(flagName, defaultString, ErrorType.None, cancelledReason); + }); + featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); + featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); + + await Api.Instance.SetProviderAsync(domain, featureProviderMock); + var client = Api.Instance.GetClient(domain, clientVersion); + var task = client.GetStringDetailsAsync(flagName, defaultString, EvaluationContext.Empty, null, cts.Token); + cts.Cancel(); // cancel before awaiting + + var response = await task; + Assert.Equal(defaultString, response.Value); + Assert.Equal(cancelledReason, response.Reason); + _ = featureProviderMock.Received(1).ResolveStringValueAsync(flagName, defaultString, Arg.Any(), cts.Token); + } - [Fact] - public void Should_Throw_ArgumentNullException_When_Provider_Is_Null() + [Fact] + public void Should_Get_And_Set_Context() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var KEY = "key"; + var VAL = 1; + FeatureClient client = Api.Instance.GetClient(domain, clientVersion); + client.SetContext(new EvaluationContextBuilder().Set(KEY, VAL).Build()); + Assert.Equal(VAL, client.GetContext().GetValue(KEY).AsInteger); + } + + + [Fact] + public void ToFlagEvaluationDetails_Should_Convert_All_Properties() + { + var fixture = new Fixture(); + var flagName = fixture.Create(); + var boolValue = fixture.Create(); + var errorType = fixture.Create(); + var reason = fixture.Create(); + var variant = fixture.Create(); + var errorMessage = fixture.Create(); + var flagData = fixture + .CreateMany>(10) + .ToDictionary(x => x.Key, x => x.Value); + var flagMetadata = new ImmutableMetadata(flagData); + + var expected = new ResolutionDetails(flagName, boolValue, errorType, reason, variant, errorMessage, flagMetadata); + var result = expected.ToFlagEvaluationDetails(); + + Assert.Equivalent(expected, result); + } + + [Fact] + [Specification("6.1.1", "The `client` MUST define a function for tracking the occurrence of a particular action or application state, with parameters `tracking event name` (string, required), `evaluation context` (optional) and `tracking event details` (optional), which returns nothing.")] + [Specification("6.1.2", "The `client` MUST define a function for tracking the occurrence of a particular action or application state, with parameters `tracking event name` (string, required) and `tracking event details` (optional), which returns nothing.")] + [Specification("6.1.4", "If the client's `track` function is called and the associated provider does not implement tracking, the client's `track` function MUST no-op.")] + public async Task TheClient_ImplementsATrackingFunction() + { + var provider = new TestProvider(); + await Api.Instance.SetProviderAsync(provider); + var client = Api.Instance.GetClient(); + + const string trackingEventName = "trackingEventName"; + var trackingEventDetails = new TrackingEventDetailsBuilder().Build(); + client.Track(trackingEventName); + client.Track(trackingEventName, EvaluationContext.Empty); + client.Track(trackingEventName, EvaluationContext.Empty, trackingEventDetails); + client.Track(trackingEventName, trackingEventDetails: trackingEventDetails); + + Assert.Equal(4, provider.GetTrackingInvocations().Count); + Assert.Equal(trackingEventName, provider.GetTrackingInvocations()[0].Item1); + Assert.Equal(trackingEventName, provider.GetTrackingInvocations()[1].Item1); + Assert.Equal(trackingEventName, provider.GetTrackingInvocations()[2].Item1); + Assert.Equal(trackingEventName, provider.GetTrackingInvocations()[3].Item1); + + Assert.NotNull(provider.GetTrackingInvocations()[0].Item2); + Assert.NotNull(provider.GetTrackingInvocations()[1].Item2); + Assert.NotNull(provider.GetTrackingInvocations()[2].Item2); + Assert.NotNull(provider.GetTrackingInvocations()[3].Item2); + + Assert.Equal(EvaluationContext.Empty.Count, provider.GetTrackingInvocations()[0].Item2!.Count); + Assert.Equal(EvaluationContext.Empty.Count, provider.GetTrackingInvocations()[1].Item2!.Count); + Assert.Equal(EvaluationContext.Empty.Count, provider.GetTrackingInvocations()[2].Item2!.Count); + Assert.Equal(EvaluationContext.Empty.Count, provider.GetTrackingInvocations()[3].Item2!.Count); + + Assert.Equal(EvaluationContext.Empty.TargetingKey, provider.GetTrackingInvocations()[0].Item2!.TargetingKey); + Assert.Equal(EvaluationContext.Empty.TargetingKey, provider.GetTrackingInvocations()[1].Item2!.TargetingKey); + Assert.Equal(EvaluationContext.Empty.TargetingKey, provider.GetTrackingInvocations()[2].Item2!.TargetingKey); + Assert.Equal(EvaluationContext.Empty.TargetingKey, provider.GetTrackingInvocations()[3].Item2!.TargetingKey); + + Assert.Null(provider.GetTrackingInvocations()[0].Item3); + Assert.Null(provider.GetTrackingInvocations()[1].Item3); + Assert.Equal(trackingEventDetails, provider.GetTrackingInvocations()[2].Item3); + Assert.Equal(trackingEventDetails, provider.GetTrackingInvocations()[3].Item3); + } + + [Fact] + public async Task PassingAnEmptyStringAsTrackingEventName_ThrowsArgumentException() + { + var provider = new TestProvider(); + await Api.Instance.SetProviderAsync(provider); + var client = Api.Instance.GetClient(); + + Assert.Throws(() => client.Track("")); + } + + [Fact] + public async Task PassingABlankStringAsTrackingEventName_ThrowsArgumentException() + { + var provider = new TestProvider(); + await Api.Instance.SetProviderAsync(provider); + var client = Api.Instance.GetClient(); + + Assert.Throws(() => client.Track(" \n ")); + } + + public static TheoryData GenerateMergeEvaluationContextTestData() + { + const string key = "key"; + const string global = "global"; + const string client = "client"; + const string invocation = "invocation"; + var globalEvaluationContext = new[] { new EvaluationContextBuilder().Set(key, "global").Build(), null }; + var clientEvaluationContext = new[] { new EvaluationContextBuilder().Set(key, "client").Build(), null }; + var invocationEvaluationContext = new[] { new EvaluationContextBuilder().Set(key, "invocation").Build(), null }; + + var data = new TheoryData(); + for (int i = 0; i < 2; i++) { - TestProvider provider = null; - Assert.Throws(() => new FeatureClient(provider, "test", "test")); + for (int j = 0; j < 2; j++) + { + for (int k = 0; k < 2; k++) + { + if (i == 1 && j == 1 && k == 1) continue; + string expected; + if (k == 0) expected = invocation; + else if (j == 0) expected = client; + else expected = global; + data.Add(key, globalEvaluationContext[i], clientEvaluationContext[j], invocationEvaluationContext[k], expected); + } + } } + + return data; + } + + [Theory] + [MemberData(nameof(GenerateMergeEvaluationContextTestData))] + [Specification("6.1.3", "The evaluation context passed to the provider's track function MUST be merged in the order: API (global; lowest precedence) - transaction - client - invocation (highest precedence), with duplicate values being overwritten.")] + public async Task TheClient_MergesTheEvaluationContextInTheCorrectOrder(string key, EvaluationContext? globalEvaluationContext, EvaluationContext? clientEvaluationContext, EvaluationContext? invocationEvaluationContext, string expectedResult) + { + var provider = new TestProvider(); + await Api.Instance.SetProviderAsync(provider); + var client = Api.Instance.GetClient(); + + const string trackingEventName = "trackingEventName"; + + Api.Instance.SetContext(globalEvaluationContext); + client.SetContext(clientEvaluationContext); + client.Track(trackingEventName, invocationEvaluationContext); + Assert.Single(provider.GetTrackingInvocations()); + var actualEvaluationContext = provider.GetTrackingInvocations()[0].Item2; + Assert.NotNull(actualEvaluationContext); + Assert.NotEqual(0, actualEvaluationContext.Count); + + Assert.Equal(expectedResult, actualEvaluationContext.GetValue(key).AsString); + } + + [Fact] + [Specification("4.3.8", "'evaluation details' passed to the 'finally' stage matches the evaluation details returned to the application author")] + public async Task FinallyHook_IncludesEvaluationDetails() + { + // Arrange + var provider = new TestProvider(); + var providerHook = Substitute.For(); + provider.AddHook(providerHook); + await Api.Instance.SetProviderAsync(provider); + var client = Api.Instance.GetClient(); + + const string flagName = "flagName"; + + // Act + var evaluationDetails = await client.GetBooleanDetailsAsync(flagName, true); + + // Assert + await providerHook.Received(1).FinallyAsync(Arg.Any>(), evaluationDetails); } } diff --git a/test/OpenFeature.Tests/OpenFeatureEvaluationContextTests.cs b/test/OpenFeature.Tests/OpenFeatureEvaluationContextTests.cs index 82dc034b..630ec435 100644 --- a/test/OpenFeature.Tests/OpenFeatureEvaluationContextTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureEvaluationContextTests.cs @@ -1,81 +1,221 @@ using System; using System.Collections.Generic; using AutoFixture; -using FluentAssertions; -using OpenFeature.SDK.Model; -using OpenFeature.SDK.Tests.Internal; +using OpenFeature.Model; +using OpenFeature.Tests.Internal; using Xunit; -namespace OpenFeature.SDK.Tests +namespace OpenFeature.Tests; + +public class OpenFeatureEvaluationContextTests { - public class OpenFeatureEvaluationContextTests + [Fact] + public void Should_Merge_Two_Contexts() { - [Fact] - public void Should_Merge_Two_Contexts() - { - var context1 = new EvaluationContext(); - var context2 = new EvaluationContext(); + var contextBuilder1 = new EvaluationContextBuilder() + .Set("key1", "value1"); + var contextBuilder2 = new EvaluationContextBuilder() + .Set("key2", "value2"); + var context1 = contextBuilder1.Merge(contextBuilder2.Build()).Build(); - context1.Add("key1", "value1"); - context2.Add("key2", "value2"); + Assert.Equal(2, context1.Count); + Assert.Equal("value1", context1.GetValue("key1").AsString); + Assert.Equal("value2", context1.GetValue("key2").AsString); + } - context1.Merge(context2); + [Fact] + public void Should_Change_TargetingKey_From_OverridingContext() + { + var contextBuilder1 = new EvaluationContextBuilder() + .Set("key1", "value1") + .SetTargetingKey("targeting_key"); + var contextBuilder2 = new EvaluationContextBuilder() + .Set("key2", "value2") + .SetTargetingKey("overriding_key"); - Assert.Equal(2, context1.Count); - Assert.Equal("value1", context1["key1"]); - Assert.Equal("value2", context1["key2"]); - } + var mergeContext = contextBuilder1.Merge(contextBuilder2.Build()).Build(); - [Fact] - public void Should_Merge_TwoContexts_And_Override_Duplicates_With_RightHand_Context() - { - var context1 = new EvaluationContext(); - var context2 = new EvaluationContext(); + Assert.Equal("overriding_key", mergeContext.TargetingKey); + } - context1.Add("key1", "value1"); - context2.Add("key1", "overriden_value"); - context2.Add("key2", "value2"); + [Fact] + public void Should_Retain_TargetingKey_When_OverridingContext_TargetingKey_Value_IsEmpty() + { + var contextBuilder1 = new EvaluationContextBuilder() + .Set("key1", "value1") + .SetTargetingKey("targeting_key"); + var contextBuilder2 = new EvaluationContextBuilder() + .Set("key2", "value2"); - context1.Merge(context2); + var mergeContext = contextBuilder1.Merge(contextBuilder2.Build()).Build(); - Assert.Equal(2, context1.Count); - Assert.Equal("overriden_value", context1["key1"]); - Assert.Equal("value2", context1["key2"]); + Assert.Equal("targeting_key", mergeContext.TargetingKey); + } - context1.Remove("key1"); - Assert.Throws(() => context1["key1"]); - } + [Fact] + [Specification("3.2.2", "Evaluation context MUST be merged in the order: API (global; lowest precedence) - client - invocation - before hooks (highest precedence), with duplicate values being overwritten.")] + public void Should_Merge_TwoContexts_And_Override_Duplicates_With_RightHand_Context() + { + var contextBuilder1 = new EvaluationContextBuilder(); + var contextBuilder2 = new EvaluationContextBuilder(); - [Fact] - public void Should_Be_Able_To_Set_Value_Via_Indexer() - { - var context = new EvaluationContext(); - context["key"] = "value"; - context["key"].Should().Be("value"); - } + contextBuilder1.Set("key1", "value1"); + contextBuilder2.Set("key1", "overriden_value"); + contextBuilder2.Set("key2", "value2"); + + var context1 = contextBuilder1.Merge(contextBuilder2.Build()).Build(); + + Assert.Equal(2, context1.Count); + Assert.Equal("overriden_value", context1.GetValue("key1").AsString); + Assert.Equal("value2", context1.GetValue("key2").AsString); + } + + [Fact] + [Specification("3.1.1", "The `evaluation context` structure MUST define an optional `targeting key` field of type string, identifying the subject of the flag evaluation.")] + [Specification("3.1.2", "The evaluation context MUST support the inclusion of custom fields, having keys of type `string`, and values of type `boolean | string | number | datetime | structure`.")] + public void EvaluationContext_Should_All_Types() + { + var fixture = new Fixture(); + var now = fixture.Create(); + var structure = fixture.Create(); + var contextBuilder = new EvaluationContextBuilder() + .SetTargetingKey("targeting_key") + .Set("targeting_key", "userId") + .Set("key1", "value") + .Set("key2", 1) + .Set("key3", true) + .Set("key4", now) + .Set("key5", structure) + .Set("key6", 1.0); + + var context = contextBuilder.Build(); + + Assert.Equal("targeting_key", context.TargetingKey); + var targetingKeyValue = context.GetValue(context.TargetingKey!); + Assert.True(targetingKeyValue.IsString); + Assert.Equal("userId", targetingKeyValue.AsString); + + var value1 = context.GetValue("key1"); + Assert.True(value1.IsString); + Assert.Equal("value", value1.AsString); + + var value2 = context.GetValue("key2"); + Assert.True(value2.IsNumber); + Assert.Equal(1, value2.AsInteger); + + var value3 = context.GetValue("key3"); + Assert.True(value3.IsBoolean); + Assert.True(value3.AsBoolean); + + var value4 = context.GetValue("key4"); + Assert.True(value4.IsDateTime); + Assert.Equal(now, value4.AsDateTime); + + var value5 = context.GetValue("key5"); + Assert.True(value5.IsStructure); + Assert.Equal(structure, value5.AsStructure); + + var value6 = context.GetValue("key6"); + Assert.True(value6.IsNumber); + Assert.Equal(1.0, value6.AsDouble); + } - [Fact] - [Specification("3.1", "The `evaluation context` structure MUST define an optional `targeting key` field of type string, identifying the subject of the flag evaluation.")] - [Specification("3.2", "The evaluation context MUST support the inclusion of custom fields, having keys of type `string`, and values of type `boolean | string | number | datetime | structure`.")] - public void EvaluationContext_Should_All_Types() + [Fact] + [Specification("3.1.4", "The evaluation context fields MUST have an unique key.")] + public void When_Duplicate_Key_Set_It_Replaces_Value() + { + var contextBuilder = new EvaluationContextBuilder().Set("key", "value"); + contextBuilder.Set("key", "overriden_value"); + Assert.Equal("overriden_value", contextBuilder.Build().GetValue("key").AsString); + } + + [Fact] + [Specification("3.1.3", "The evaluation context MUST support fetching the custom fields by key and also fetching all key value pairs.")] + public void Should_Be_Able_To_Get_All_Values() + { + var context = new EvaluationContextBuilder() + .Set("key1", "value1") + .Set("key2", "value2") + .Set("key3", "value3") + .Set("key4", "value4") + .Set("key5", "value5") + .Build(); + + // Iterate over key value pairs and check consistency + var count = 0; + foreach (var keyValue in context) { - var fixture = new Fixture(); - var now = fixture.Create(); - var structure = fixture.Create(); - var context = new EvaluationContext - { - { "key1", "value" }, - { "key2", 1 }, - { "key3", true }, - { "key4", now }, - { "key5", structure} - }; - - context.Get("key1").Should().Be("value"); - context.Get("key2").Should().Be(1); - context.Get("key3").Should().Be(true); - context.Get("key4").Should().Be(now); - context.Get("key5").Should().Be(structure); + Assert.Equal(keyValue.Value.AsString, context.GetValue(keyValue.Key).AsString); + count++; } + + Assert.Equal(count, context.Count); + } + + [Fact] + public void TryGetValue_WhenCalledWithExistingKey_ReturnsTrueAndExpectedValue() + { + // Arrange + var key = "testKey"; + var expectedValue = new Value("testValue"); + var structure = new Structure(new Dictionary { { key, expectedValue } }); + var evaluationContext = new EvaluationContext(structure); + + // Act + var result = evaluationContext.TryGetValue(key, out var actualValue); + + // Assert + Assert.True(result); + Assert.Equal(expectedValue, actualValue); + } + + [Fact] + public void GetValueOnTargetingKeySetWithTargetingKey_Equals_TargetingKey() + { + // Arrange + var value = "my_targeting_key"; + var evaluationContext = EvaluationContext.Builder().SetTargetingKey(value).Build(); + + // Act + var result = evaluationContext.TryGetValue(EvaluationContext.TargetingKeyIndex, out var actualFromStructure); + var actualFromTargetingKey = evaluationContext.TargetingKey; + + // Assert + Assert.True(result); + Assert.Equal(value, actualFromStructure?.AsString); + Assert.Equal(value, actualFromTargetingKey); + } + + [Fact] + public void GetValueOnTargetingKeySetWithStructure_Equals_TargetingKey() + { + // Arrange + var value = "my_targeting_key"; + var evaluationContext = EvaluationContext.Builder().Set(EvaluationContext.TargetingKeyIndex, new Value(value)).Build(); + + // Act + var result = evaluationContext.TryGetValue(EvaluationContext.TargetingKeyIndex, out var actualFromStructure); + var actualFromTargetingKey = evaluationContext.TargetingKey; + + // Assert + Assert.True(result); + Assert.Equal(value, actualFromStructure?.AsString); + Assert.Equal(value, actualFromTargetingKey); + } + + [Fact] + public void GetValueOnTargetingKeySetWithNonStringValue_Equals_Null() + { + // Arrange + var evaluationContext = EvaluationContext.Builder().Set(EvaluationContext.TargetingKeyIndex, new Value(1)).Build(); + + // Act + var result = evaluationContext.TryGetValue(EvaluationContext.TargetingKeyIndex, out var actualFromStructure); + var actualFromTargetingKey = evaluationContext.TargetingKey; + + // Assert + Assert.True(result); + Assert.Null(actualFromStructure?.AsString); + Assert.Null(actualFromTargetingKey); } } diff --git a/test/OpenFeature.Tests/OpenFeatureEventTests.cs b/test/OpenFeature.Tests/OpenFeatureEventTests.cs new file mode 100644 index 00000000..d47a530f --- /dev/null +++ b/test/OpenFeature.Tests/OpenFeatureEventTests.cs @@ -0,0 +1,517 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Tests.Internal; +using Xunit; + +namespace OpenFeature.Tests; + +public class OpenFeatureEventTest : ClearOpenFeatureInstanceFixture +{ + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + public async Task Event_Executor_Should_Propagate_Events_ToGlobal_Handler() + { + var eventHandler = Substitute.For(); + + var eventExecutor = new EventExecutor(); + + eventExecutor.AddApiLevelHandler(ProviderEventTypes.ProviderConfigurationChanged, eventHandler); + + var eventMetadata = new ImmutableMetadata(new Dictionary { { "foo", "bar" } }); + var myEvent = new Event + { + EventPayload = new ProviderEventPayload + { + Type = ProviderEventTypes.ProviderConfigurationChanged, + Message = "The provider is ready", + EventMetadata = eventMetadata, + FlagsChanged = new List + { + "flag1", "flag2" + } + } + }; + eventExecutor.EventChannel.Writer.TryWrite(myEvent); + + Thread.Sleep(1000); + + eventHandler.Received().Invoke(Arg.Is(payload => payload == myEvent.EventPayload)); + + // shut down the event executor + await eventExecutor.ShutdownAsync(); + + // the next event should not be propagated to the event handler + var newEventPayload = new ProviderEventPayload + { + Type = ProviderEventTypes.ProviderStale + }; + + eventExecutor.EventChannel.Writer.TryWrite(newEventPayload); + + eventHandler.DidNotReceive().Invoke(newEventPayload); + + eventHandler.DidNotReceive().Invoke(Arg.Is(payload => payload.Type == ProviderEventTypes.ProviderStale)); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + public async Task API_Level_Event_Handlers_Should_Be_Registered() + { + var eventHandler = Substitute.For(); + + Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + Api.Instance.AddHandler(ProviderEventTypes.ProviderConfigurationChanged, eventHandler); + Api.Instance.AddHandler(ProviderEventTypes.ProviderError, eventHandler); + Api.Instance.AddHandler(ProviderEventTypes.ProviderStale, eventHandler); + + var testProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(testProvider); + + await testProvider.SendEventAsync(ProviderEventTypes.ProviderConfigurationChanged); + await testProvider.SendEventAsync(ProviderEventTypes.ProviderError); + await testProvider.SendEventAsync(ProviderEventTypes.ProviderStale); + + await Utils.AssertUntilAsync(_ => eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderReady + ))); + + await Utils.AssertUntilAsync(_ => eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderConfigurationChanged + ))); + + await Utils.AssertUntilAsync(_ => eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderError + ))); + + await Utils.AssertUntilAsync(_ => eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderStale + ))); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.3.1", "If the provider's `initialize` function terminates normally, `PROVIDER_READY` handlers MUST run.")] + [Specification("5.3.3", "Handlers attached after the provider is already in the associated state, MUST run immediately.")] + public async Task API_Level_Event_Handlers_Should_Be_Informed_About_Ready_State_After_Registering_Provider_Ready() + { + var eventHandler = Substitute.For(); + + var testProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(testProvider); + + Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + + await Utils.AssertUntilAsync(_ => eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderReady + ))); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.3.1", "If the provider's `initialize` function terminates normally, `PROVIDER_READY` handlers MUST run.")] + [Specification("5.3.3", "Handlers attached after the provider is already in the associated state, MUST run immediately.")] + public async Task API_Level_Event_Handlers_Should_Be_Informed_About_Ready_State_After_Registering_Provider_Ready_Sync() + { + var eventHandler = Substitute.For(); + + var testProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(testProvider); + + Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + + await Utils.AssertUntilAsync(_ => eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderReady + ))); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.3.2", "If the provider's `initialize` function terminates abnormally, `PROVIDER_ERROR` handlers MUST run.")] + [Specification("5.3.3", "Handlers attached after the provider is already in the associated state, MUST run immediately.")] + public async Task API_Level_Event_Handlers_Should_Be_Informed_About_Error_State_After_Registering_Provider_Error() + { + var eventHandler = Substitute.For(); + + var testProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(testProvider); + + testProvider.Status = ProviderStatus.Error; + + Api.Instance.AddHandler(ProviderEventTypes.ProviderError, eventHandler); + + await Utils.AssertUntilAsync(_ => eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderError + ))); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.3.3", "Handlers attached after the provider is already in the associated state, MUST run immediately.")] + public async Task API_Level_Event_Handlers_Should_Be_Informed_About_Stale_State_After_Registering_Provider_Stale() + { + var eventHandler = Substitute.For(); + + var testProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(testProvider); + + testProvider.Status = ProviderStatus.Stale; + + Api.Instance.AddHandler(ProviderEventTypes.ProviderStale, eventHandler); + + await Utils.AssertUntilAsync(_ => eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderStale + ))); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.2.6", "Event handlers MUST persist across `provider` changes.")] + public async Task API_Level_Event_Handlers_Should_Be_Exchangeable() + { + var eventHandler = Substitute.For(); + + Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + Api.Instance.AddHandler(ProviderEventTypes.ProviderConfigurationChanged, eventHandler); + + var testProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(testProvider); + + await testProvider.SendEventAsync(ProviderEventTypes.ProviderConfigurationChanged); + + var newTestProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(newTestProvider); + + await newTestProvider.SendEventAsync(ProviderEventTypes.ProviderConfigurationChanged); + + await Utils.AssertUntilAsync( + _ => eventHandler.Received(2).Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderReady)) + ); + await Utils.AssertUntilAsync( + _ => eventHandler.Received(2).Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderConfigurationChanged)) + ); + } + + [Fact] + [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.2.7", "The `API` and `client` MUST provide a function allowing the removal of event handlers.")] + public async Task API_Level_Event_Handlers_Should_Be_Removable() + { + var eventHandler = Substitute.For(); + + Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + + var testProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(testProvider); + + Thread.Sleep(1000); + Api.Instance.RemoveHandler(ProviderEventTypes.ProviderReady, eventHandler); + + var newTestProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(newTestProvider); + + eventHandler.Received(1).Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.2.5", "If a `handler function` terminates abnormally, other `handler` functions MUST run.")] + public async Task API_Level_Event_Handlers_Should_Be_Executed_When_Other_Handler_Fails() + { + var fixture = new Fixture(); + + var failingEventHandler = Substitute.For(); + var eventHandler = Substitute.For(); + + failingEventHandler.When(x => x.Invoke(Arg.Any())) + .Do(x => throw new Exception()); + + Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, failingEventHandler); + Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + + var testProvider = new TestProvider(fixture.Create()); + await Api.Instance.SetProviderAsync(testProvider); + + await Utils.AssertUntilAsync( + _ => failingEventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)) + ); + await Utils.AssertUntilAsync( + _ => eventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)) + ); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + public async Task Client_Level_Event_Handlers_Should_Be_Registered() + { + var fixture = new Fixture(); + var eventHandler = Substitute.For(); + + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var myClient = Api.Instance.GetClient(domain, clientVersion); + + var testProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(myClient.GetMetadata().Name!, testProvider); + + myClient.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + + eventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.2.5", "If a `handler function` terminates abnormally, other `handler` functions MUST run.")] + public async Task Client_Level_Event_Handlers_Should_Be_Executed_When_Other_Handler_Fails() + { + var fixture = new Fixture(); + + var failingEventHandler = Substitute.For(); + var eventHandler = Substitute.For(); + + failingEventHandler.When(x => x.Invoke(Arg.Any())) + .Do(x => throw new Exception()); + + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var myClient = Api.Instance.GetClient(domain, clientVersion); + + myClient.AddHandler(ProviderEventTypes.ProviderReady, failingEventHandler); + myClient.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + + var testProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(myClient.GetMetadata().Name!, testProvider); + + await Utils.AssertUntilAsync( + _ => failingEventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)) + ); + await Utils.AssertUntilAsync( + _ => eventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)) + ); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.1.3", "When a `provider` signals the occurrence of a particular `event`, event handlers on clients which are not associated with that provider MUST NOT run.")] + [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + public async Task Client_Level_Event_Handlers_Should_Be_Registered_To_Default_Provider() + { + var fixture = new Fixture(); + var eventHandler = Substitute.For(); + var clientEventHandler = Substitute.For(); + + var myClientWithNoBoundProvider = Api.Instance.GetClient(fixture.Create(), fixture.Create()); + var myClientWithBoundProvider = Api.Instance.GetClient(fixture.Create(), fixture.Create()); + + var apiProvider = new TestProvider(fixture.Create()); + var clientProvider = new TestProvider(fixture.Create()); + + // set the default provider on API level, but not specifically to the client + await Api.Instance.SetProviderAsync(apiProvider); + // set the other provider specifically for the client + await Api.Instance.SetProviderAsync(myClientWithBoundProvider.GetMetadata().Name!, clientProvider); + + myClientWithNoBoundProvider.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + myClientWithBoundProvider.AddHandler(ProviderEventTypes.ProviderReady, clientEventHandler); + + eventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == apiProvider.GetMetadata().Name)); + eventHandler.DidNotReceive().Invoke(Arg.Is(payload => payload.ProviderName == clientProvider.GetMetadata().Name)); + + clientEventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == clientProvider.GetMetadata().Name)); + clientEventHandler.DidNotReceive().Invoke(Arg.Is(payload => payload.ProviderName == apiProvider.GetMetadata().Name)); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.1.3", "When a `provider` signals the occurrence of a particular `event`, event handlers on clients which are not associated with that provider MUST NOT run.")] + [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.2.6", "Event handlers MUST persist across `provider` changes.")] + public async Task Client_Level_Event_Handlers_Should_Be_Receive_Events_From_Named_Provider_Instead_of_Default() + { + var fixture = new Fixture(); + var clientEventHandler = Substitute.For(); + + var client = Api.Instance.GetClient(fixture.Create(), fixture.Create()); + + var defaultProvider = new TestProvider(fixture.Create()); + var clientProvider = new TestProvider(fixture.Create()); + + // set the default provider + await Api.Instance.SetProviderAsync(defaultProvider); + + client.AddHandler(ProviderEventTypes.ProviderConfigurationChanged, clientEventHandler); + + await defaultProvider.SendEventAsync(ProviderEventTypes.ProviderConfigurationChanged); + + // verify that the client received the event from the default provider as there is no named provider registered yet + await Utils.AssertUntilAsync( + _ => clientEventHandler.Received(1) + .Invoke(Arg.Is(payload => payload.ProviderName == defaultProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderConfigurationChanged)) + ); + + // set the other provider specifically for the client + await Api.Instance.SetProviderAsync(client.GetMetadata().Name!, clientProvider); + + // now, send another event for the default handler + await defaultProvider.SendEventAsync(ProviderEventTypes.ProviderConfigurationChanged); + await clientProvider.SendEventAsync(ProviderEventTypes.ProviderConfigurationChanged); + + // now the client should have received only the event from the named provider + await Utils.AssertUntilAsync( + _ => clientEventHandler.Received(1).Invoke(Arg.Is(payload => payload.ProviderName == clientProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderConfigurationChanged)) + ); + // for the default provider, the number of received events should stay unchanged + await Utils.AssertUntilAsync( + _ => clientEventHandler.Received(1) + .Invoke(Arg.Is(payload => payload.ProviderName == defaultProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderConfigurationChanged)) + ); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.3.1", "If the provider's `initialize` function terminates normally, `PROVIDER_READY` handlers MUST run.")] + [Specification("5.3.3", "Handlers attached after the provider is already in the associated state, MUST run immediately.")] + public async Task Client_Level_Event_Handlers_Should_Be_Informed_About_Ready_State_After_Registering() + { + var fixture = new Fixture(); + var eventHandler = Substitute.For(); + + var myClient = Api.Instance.GetClient(fixture.Create(), fixture.Create()); + + var testProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(myClient.GetMetadata().Name!, testProvider); + + // add the event handler after the provider has already transitioned into the ready state + myClient.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + + eventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)); + } + + [Fact] + [Specification("5.1.3", "When a `provider` signals the occurrence of a particular `event`, event handlers on clients which are not associated with that provider MUST NOT run.")] + [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.2.7", "The `API` and `client` MUST provide a function allowing the removal of event handlers.")] + public async Task Client_Level_Event_Handlers_Should_Be_Removable() + { + var fixture = new Fixture(); + + var eventHandler = Substitute.For(); + + var myClient = Api.Instance.GetClient(fixture.Create(), fixture.Create()); + + myClient.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + + var testProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(myClient.GetMetadata().Name!, testProvider); + + // wait for the first event to be received + await Utils.AssertUntilAsync( + _ => eventHandler.Received(1).Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)) + ); + + myClient.RemoveHandler(ProviderEventTypes.ProviderReady, eventHandler); + + // send another event from the provider - this one should not be received + await testProvider.SendEventAsync(ProviderEventTypes.ProviderReady); + + // wait a bit and make sure we only have received the first event, but nothing after removing the event handler + await Utils.AssertUntilAsync( + _ => eventHandler.Received(1).Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)) + ); + } + + [Fact] + public void RegisterClientFeatureProvider_WhenCalledWithNullProvider_DoesNotThrowException() + { + // Arrange + var eventExecutor = new EventExecutor(); + string client = "testClient"; + FeatureProvider? provider = null; + + // Act + var exception = Record.Exception(() => eventExecutor.RegisterClientFeatureProvider(client, provider)); + + // Assert + Assert.Null(exception); + } + + [Theory] + [InlineData(ProviderEventTypes.ProviderError, ProviderStatus.Error)] + [InlineData(ProviderEventTypes.ProviderReady, ProviderStatus.Ready)] + [InlineData(ProviderEventTypes.ProviderStale, ProviderStatus.Stale)] + [Specification("5.3.5", "If the provider emits an event, the value of the client's provider status MUST be updated accordingly.")] + public async Task Provider_Events_Should_Update_ProviderStatus(ProviderEventTypes type, ProviderStatus status) + { + var provider = new TestProvider(); + await Api.Instance.SetProviderAsync("5.3.5", provider); + _ = provider.SendEventAsync(type); + await Utils.AssertUntilAsync(_ => Assert.True(provider.Status == status)); + } +} diff --git a/test/OpenFeature.Tests/OpenFeatureHookTests.cs b/test/OpenFeature.Tests/OpenFeatureHookTests.cs index 5fdfe764..cebe40c0 100644 --- a/test/OpenFeature.Tests/OpenFeatureHookTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureHookTests.cs @@ -1,371 +1,748 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; using System.Threading.Tasks; using AutoFixture; -using FluentAssertions; -using Moq; -using OpenFeature.SDK.Constant; -using OpenFeature.SDK.Model; -using OpenFeature.SDK.Tests.Internal; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using OpenFeature.Constant; +using OpenFeature.Error; +using OpenFeature.Model; +using OpenFeature.Tests.Internal; using Xunit; -namespace OpenFeature.SDK.Tests +namespace OpenFeature.Tests; + +public class OpenFeatureHookTests : ClearOpenFeatureInstanceFixture { - public class OpenFeatureHookTests + [Fact] + [Specification("1.5.1", "The `evaluation options` structure's `hooks` field denotes an ordered collection of hooks that the client MUST execute for the respective flag evaluation, in addition to those already configured.")] + [Specification("2.3.1", "The provider interface MUST define a `provider hook` mechanism which can be optionally implemented in order to add `hook` instances to the evaluation life-cycle.")] + [Specification("4.4.2", "Hooks MUST be evaluated in the following order: - before: API, Client, Invocation, Provider - after: Provider, Invocation, Client, API - error (if applicable): Provider, Invocation, Client, API - finally: Provider, Invocation, Client, API")] + public async Task Hooks_Should_Be_Called_In_Order() { - [Fact] - [Specification("1.5.1", "The `evaluation options` structure's `hooks` field denotes an ordered collection of hooks that the client MUST execute for the respective flag evaluation, in addition to those already configured.")] - [Specification("4.4.2", "Hooks MUST be evaluated in the following order: - before: API, Client, Invocation - after: Invocation, Client, API - error (if applicable): Invocation, Client, API - finally: Invocation, Client, API")] - public async Task Hooks_Should_Be_Called_In_Order() + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultValue = fixture.Create(); + var apiHook = Substitute.For(); + var clientHook = Substitute.For(); + var invocationHook = Substitute.For(); + var providerHook = Substitute.For(); + + // Sequence + apiHook.BeforeAsync(Arg.Any>(), Arg.Any>()).Returns(EvaluationContext.Empty); + clientHook.BeforeAsync(Arg.Any>(), Arg.Any>()).Returns(EvaluationContext.Empty); + invocationHook.BeforeAsync(Arg.Any>(), Arg.Any>()).Returns(EvaluationContext.Empty); + providerHook.BeforeAsync(Arg.Any>(), Arg.Any>()).Returns(EvaluationContext.Empty); + providerHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); + invocationHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); + clientHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); + apiHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); + providerHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); + invocationHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); + clientHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); + apiHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); + + var testProvider = new TestProvider(); + testProvider.AddHook(providerHook); + Api.Instance.AddHooks(apiHook); + await Api.Instance.SetProviderAsync(testProvider); + var client = Api.Instance.GetClient(domain, clientVersion); + client.AddHooks(clientHook); + + await client.GetBooleanValueAsync(flagName, defaultValue, EvaluationContext.Empty, + new FlagEvaluationOptions(invocationHook, ImmutableDictionary.Empty)); + + Received.InOrder(() => { - var fixture = new Fixture(); - var clientName = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultValue = fixture.Create(); - var clientHook = new Mock(); - var invocationHook = new Mock(); + apiHook.BeforeAsync(Arg.Any>(), Arg.Any>()); + clientHook.BeforeAsync(Arg.Any>(), Arg.Any>()); + invocationHook.BeforeAsync(Arg.Any>(), Arg.Any>()); + providerHook.BeforeAsync(Arg.Any>(), Arg.Any>()); + providerHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + invocationHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + clientHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + apiHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + providerHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + invocationHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + clientHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + apiHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + }); + + _ = apiHook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); + _ = clientHook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); + _ = invocationHook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); + _ = providerHook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); + _ = providerHook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = invocationHook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = clientHook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = apiHook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = providerHook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = invocationHook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = clientHook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = apiHook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + } - var sequence = new MockSequence(); + [Fact] + [Specification("4.1.1", "Hook context MUST provide: the `flag key`, `flag value type`, `evaluation context`, `default value`, and `hook data`.")] + public void Hook_Context_Should_Not_Allow_Nulls() + { + Assert.Throws(() => + new HookContext(null, Structure.Empty, FlagValueType.Object, new ClientMetadata(null, null), + new Metadata(null), EvaluationContext.Empty)); + + Assert.Throws(() => + new HookContext("test", Structure.Empty, FlagValueType.Object, null, + new Metadata(null), EvaluationContext.Empty)); + + Assert.Throws(() => + new HookContext("test", Structure.Empty, FlagValueType.Object, new ClientMetadata(null, null), + null, EvaluationContext.Empty)); + + Assert.Throws(() => + new HookContext("test", Structure.Empty, FlagValueType.Object, new ClientMetadata(null, null), + new Metadata(null), null)); + + Assert.Throws(() => new SharedHookContext("test", Structure.Empty, FlagValueType.Object, + new ClientMetadata(null, null), new Metadata(null)).ToHookContext(null)); + + Assert.Throws(() => + new HookContext(null, EvaluationContext.Empty, + new HookData())); + + Assert.Throws(() => + new HookContext( + new SharedHookContext("test", Structure.Empty, FlagValueType.Object, + new ClientMetadata(null, null), new Metadata(null)), EvaluationContext.Empty, + null)); + } - invocationHook.InSequence(sequence).Setup(x => x.Before(It.IsAny>(), - It.IsAny>())) - .ReturnsAsync(new EvaluationContext()); + [Fact] + [Specification("4.1.2", "The `hook context` SHOULD provide: access to the `client metadata` and the `provider metadata` fields.")] + [Specification("4.1.3", "The `flag key`, `flag type`, and `default value` properties MUST be immutable. If the language does not support immutability, the hook MUST NOT modify these properties.")] + public void Hook_Context_Should_Have_Properties_And_Be_Immutable() + { + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var testStructure = Structure.Empty; + var context = new HookContext("test", testStructure, FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + + Assert.Equal(clientMetadata, context.ClientMetadata); + Assert.Equal(providerMetadata, context.ProviderMetadata); + Assert.Equal("test", context.FlagKey); + Assert.Equal(testStructure, context.DefaultValue); + Assert.Equal(FlagValueType.Object, context.FlagValueType); + } - clientHook.InSequence(sequence).Setup(x => x.Before(It.IsAny>(), - It.IsAny>())) - .ReturnsAsync(new EvaluationContext()); + [Fact] + [Specification("4.1.4", "The evaluation context MUST be mutable only within the `before` hook.")] + [Specification("4.3.3", "Any `evaluation context` returned from a `before` hook MUST be passed to subsequent `before` hooks (via `HookContext`).")] + public async Task Evaluation_Context_Must_Be_Mutable_Before_Hook() + { + var evaluationContext = new EvaluationContextBuilder().Set("test", "test").Build(); + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); + var hookContext = new HookContext("test", false, + FlagValueType.Boolean, new ClientMetadata("test", "1.0.0"), new Metadata(NoOpProvider.NoOpProviderName), + evaluationContext); + + hook1.BeforeAsync(Arg.Any>(), Arg.Any>()).Returns(evaluationContext); + hook2.BeforeAsync(hookContext, Arg.Any>()).Returns(evaluationContext); + + var client = Api.Instance.GetClient("test", "1.0.0"); + await client.GetBooleanValueAsync("test", false, EvaluationContext.Empty, + new FlagEvaluationOptions(ImmutableList.Create(hook1, hook2), ImmutableDictionary.Empty)); + + _ = hook1.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); + _ = hook2.Received(1).BeforeAsync(Arg.Is>(a => a.EvaluationContext.GetValue("test").AsString == "test"), Arg.Any>()); + } - invocationHook.InSequence(sequence).Setup(x => x.After(It.IsAny>(), - It.IsAny>(), - It.IsAny>())); + [Fact] + [Specification("4.1.5", "The `hook data` MUST be mutable.")] + public async Task HookData_Must_Be_Mutable() + { + var hook = Substitute.For(); - clientHook.InSequence(sequence).Setup(x => x.After(It.IsAny>(), - It.IsAny>(), - It.IsAny>())); + hook.BeforeAsync(Arg.Any>(), Arg.Any>()) + .Returns(EvaluationContext.Empty).AndDoes(info => + { + info.Arg>().Data.Set("test-a", true); + }); + hook.AfterAsync(Arg.Any>(), Arg.Any>(), + Arg.Any>()).Returns(new ValueTask()).AndDoes(info => + { + info.Arg>().Data.Set("test-b", "test-value"); + }); - invocationHook.InSequence(sequence).Setup(x => x.Finally(It.IsAny>(), - It.IsAny>())); + await Api.Instance.SetProviderAsync(new NoOpFeatureProvider()); + var client = Api.Instance.GetClient("test", "1.0.0"); - clientHook.InSequence(sequence).Setup(x => x.Finally(It.IsAny>(), - It.IsAny>())); + await client.GetBooleanValueAsync("test", false, EvaluationContext.Empty, + new FlagEvaluationOptions(ImmutableList.Create(hook), ImmutableDictionary.Empty)); - OpenFeature.Instance.SetProvider(new NoOpFeatureProvider()); - var client = OpenFeature.Instance.GetClient(clientName, clientVersion); - client.AddHooks(clientHook.Object); + _ = hook.Received(1).AfterAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("test-a") == true + ), Arg.Any>(), Arg.Any>()); + _ = hook.Received(1).FinallyAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("test-a") == true && (string)hookContext.Data.Get("test-b") == "test-value" + ), Arg.Any>(), Arg.Any>()); + } - await client.GetBooleanValue(flagName, defaultValue, new EvaluationContext(), - new FlagEvaluationOptions(invocationHook.Object, new Dictionary())); + [Fact] + [Specification("4.3.2", + "`Hook data` **MUST** must be created before the first `stage` invoked in a hook for a specific evaluation and propagated between each `stage` of the hook. The hook data is not shared between different hooks.")] + public async Task HookData_Must_Be_Unique_Per_Hook() + { + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); - invocationHook.Verify(x => x.Before( - It.IsAny>(), It.IsAny>()), Times.Once); + hook1.BeforeAsync(Arg.Any>(), Arg.Any>()) + .Returns(EvaluationContext.Empty).AndDoes(info => + { + info.Arg>().Data.Set("hook-1-value-a", true); + info.Arg>().Data.Set("same", true); + }); + hook1.AfterAsync(Arg.Any>(), Arg.Any>(), + Arg.Any>()).Returns(new ValueTask()).AndDoes(info => + { + info.Arg>().Data.Set("hook-1-value-b", "test-value-hook-1"); + }); - clientHook.Verify(x => x.Before( - It.IsAny>(), It.IsAny>()), Times.Once); + hook2.BeforeAsync(Arg.Any>(), Arg.Any>()) + .Returns(EvaluationContext.Empty).AndDoes(info => + { + info.Arg>().Data.Set("hook-2-value-a", false); + info.Arg>().Data.Set("same", false); + }); + hook2.AfterAsync(Arg.Any>(), Arg.Any>(), + Arg.Any>()).Returns(new ValueTask()).AndDoes(info => + { + info.Arg>().Data.Set("hook-2-value-b", "test-value-hook-2"); + }); + + await Api.Instance.SetProviderAsync(new NoOpFeatureProvider()); + var client = Api.Instance.GetClient("test", "1.0.0"); + + await client.GetBooleanValueAsync("test", false, EvaluationContext.Empty, + new FlagEvaluationOptions(ImmutableList.Create(hook1, hook2), + ImmutableDictionary.Empty)); + + _ = hook1.Received(1).AfterAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("hook-1-value-a") == true && (bool)hookContext.Data.Get("same") == true + ), Arg.Any>(), Arg.Any>()); + _ = hook1.Received(1).FinallyAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("hook-1-value-a") == true && + (bool)hookContext.Data.Get("same") == true && + (string)hookContext.Data.Get("hook-1-value-b") == "test-value-hook-1" && hookContext.Data.Count == 3 + ), Arg.Any>(), Arg.Any>()); + + _ = hook2.Received(1).AfterAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("hook-2-value-a") == false && (bool)hookContext.Data.Get("same") == false + ), Arg.Any>(), Arg.Any>()); + _ = hook2.Received(1).FinallyAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("hook-2-value-a") == false && + (bool)hookContext.Data.Get("same") == false && + (string)hookContext.Data.Get("hook-2-value-b") == "test-value-hook-2" && hookContext.Data.Count == 3 + ), Arg.Any>(), Arg.Any>()); + } - invocationHook.Verify(x => x.After( - It.IsAny>(), It.IsAny>(), It.IsAny>()), Times.Once); + [Fact] + [Specification("3.2.2", "Evaluation context MUST be merged in the order: API (global; lowest precedence) - client - invocation - before hooks (highest precedence), with duplicate values being overwritten.")] + [Specification("4.3.4", "When `before` hooks have finished executing, any resulting `evaluation context` MUST be merged with the existing `evaluation context`.")] + public async Task Evaluation_Context_Must_Be_Merged_In_Correct_Order() + { + var propGlobal = "4.3.4global"; + var propGlobalToOverwrite = "4.3.4globalToOverwrite"; - clientHook.Verify(x => x.After( - It.IsAny>(), It.IsAny>(), It.IsAny>()), Times.Once); + var propClient = "4.3.4client"; + var propClientToOverwrite = "4.3.4clientToOverwrite"; - invocationHook.Verify(x => x.Finally( - It.IsAny>(), It.IsAny>()), Times.Once); + var propInvocation = "4.3.4invocation"; + var propInvocationToOverwrite = "4.3.4invocationToOverwrite"; - clientHook.Verify(x => x.Finally( - It.IsAny>(), It.IsAny>()), Times.Once); - } + var propTransaction = "4.3.4transaction"; + var propTransactionToOverwrite = "4.3.4transactionToOverwrite"; - [Fact] - [Specification("4.1.1", "Hook context MUST provide: the `flag key`, `flag value type`, `evaluation context`, and the `default value`.")] - public void Hook_Context_Should_Not_Allow_Nulls() - { - Assert.Throws(() => - new HookContext(null, new TestStructure(), FlagValueType.Object, new ClientMetadata(null, null), - new Metadata(null), new EvaluationContext())); - - Assert.Throws(() => - new HookContext("test", new TestStructure(), FlagValueType.Object, null, - new Metadata(null), new EvaluationContext())); - - Assert.Throws(() => - new HookContext("test", new TestStructure(), FlagValueType.Object, new ClientMetadata(null, null), - null, new EvaluationContext())); - - Assert.Throws(() => - new HookContext("test", new TestStructure(), FlagValueType.Object, new ClientMetadata(null, null), - new Metadata(null), null)); - } - - [Fact] - [Specification("4.1.2", "The `hook context` SHOULD provide: access to the `client metadata` and the `provider metadata` fields.")] - [Specification("4.1.3", "The `flag key`, `flag type`, and `default value` properties MUST be immutable. If the language does not support immutability, the hook MUST NOT modify these properties.")] - public void Hook_Context_Should_Have_Properties_And_Be_Immutable() - { - var clientMetadata = new ClientMetadata("client", "1.0.0"); - var providerMetadata = new Metadata("provider"); - var testStructure = new TestStructure(); - var context = new HookContext("test", testStructure, FlagValueType.Object, clientMetadata, - providerMetadata, new EvaluationContext()); - - context.ClientMetadata.Should().BeSameAs(clientMetadata); - context.ProviderMetadata.Should().BeSameAs(providerMetadata); - context.FlagKey.Should().Be("test"); - context.DefaultValue.Should().BeSameAs(testStructure); - context.FlagValueType.Should().Be(FlagValueType.Object); - } - - [Fact] - [Specification("4.1.4", "The evaluation context MUST be mutable only within the `before` hook.")] - [Specification("4.3.3", "Any `evaluation context` returned from a `before` hook MUST be passed to subsequent `before` hooks (via `HookContext`).")] - [Specification("4.3.4", "When `before` hooks have finished executing, any resulting `evaluation context` MUST be merged with the invocation `evaluation context` with the invocation `evaluation context` taking precedence in the case of any conflicts.")] - public async Task Evaluation_Context_Must_Be_Mutable_Before_Hook() + var propHook = "4.3.4hook"; + + // setup a cascade of overwriting properties + Api.Instance.SetContext(new EvaluationContextBuilder() + .Set(propGlobal, true) + .Set(propGlobalToOverwrite, false) + .Build()); + + var clientContext = new EvaluationContextBuilder() + .Set(propClient, true) + .Set(propGlobalToOverwrite, true) + .Set(propClientToOverwrite, false) + .Build(); + + var transactionContext = new EvaluationContextBuilder() + .Set(propTransaction, true) + .Set(propInvocationToOverwrite, true) + .Set(propTransactionToOverwrite, false) + .Build(); + + var invocationContext = new EvaluationContextBuilder() + .Set(propInvocation, true) + .Set(propClientToOverwrite, true) + .Set(propTransactionToOverwrite, true) + .Set(propInvocationToOverwrite, false) + .Build(); + + + var hookContext = new EvaluationContextBuilder() + .Set(propHook, true) + .Set(propInvocationToOverwrite, true) + .Build(); + + var transactionContextPropagator = new AsyncLocalTransactionContextPropagator(); + transactionContextPropagator.SetTransactionContext(transactionContext); + Api.Instance.SetTransactionContextPropagator(transactionContextPropagator); + + var provider = Substitute.For(); + + provider.GetMetadata().Returns(new Metadata(null)); + + provider.GetProviderHooks().Returns(ImmutableList.Empty); + + provider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new ResolutionDetails("test", true)); + + await Api.Instance.SetProviderAsync(provider); + + var hook = Substitute.For(); + hook.BeforeAsync(Arg.Any>(), Arg.Any>()).Returns(hookContext); + + + var client = Api.Instance.GetClient("test", "1.0.0", null, clientContext); + await client.GetBooleanValueAsync("test", false, invocationContext, new FlagEvaluationOptions(ImmutableList.Create(hook), ImmutableDictionary.Empty)); + + // after proper merging, all properties should equal true + _ = provider.Received(1).ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Is(y => + (y.GetValue(propGlobal).AsBoolean ?? false) + && (y.GetValue(propClient).AsBoolean ?? false) + && (y.GetValue(propTransaction).AsBoolean ?? false) + && (y.GetValue(propGlobalToOverwrite).AsBoolean ?? false) + && (y.GetValue(propTransactionToOverwrite).AsBoolean ?? false) + && (y.GetValue(propInvocation).AsBoolean ?? false) + && (y.GetValue(propClientToOverwrite).AsBoolean ?? false) + && (y.GetValue(propHook).AsBoolean ?? false) + && (y.GetValue(propInvocationToOverwrite).AsBoolean ?? false) + )); + } + + [Fact] + [Specification("4.2.1", "`hook hints` MUST be a structure supports definition of arbitrary properties, with keys of type `string`, and values of type `boolean | string | number | datetime | structure`..")] + [Specification("4.2.2.1", "Condition: `Hook hints` MUST be immutable.")] + [Specification("4.2.2.2", "Condition: The client `metadata` field in the `hook context` MUST be immutable.")] + [Specification("4.2.2.3", "Condition: The provider `metadata` field in the `hook context` MUST be immutable.")] + [Specification("4.3.1", "Hooks MUST specify at least one stage.")] + public async Task Hook_Should_Return_No_Errors() + { + var hook = new TestHookNoOverride(); + var hookHints = new Dictionary { - var evaluationContext = new EvaluationContext { ["test"] = "test" }; - var hook1 = new Mock(); - var hook2 = new Mock(); - var hookContext = new HookContext("test", false, - FlagValueType.Boolean, new ClientMetadata("test", "1.0.0"), new Metadata(NoOpProvider.NoOpProviderName), - evaluationContext); - - hook1.Setup(x => x.Before(It.IsAny>(), It.IsAny>())) - .ReturnsAsync(evaluationContext); - - hook2.Setup(x => - x.Before(hookContext, It.IsAny>())) - .ReturnsAsync(evaluationContext); - - var client = OpenFeature.Instance.GetClient("test", "1.0.0"); - await client.GetBooleanValue("test", false, new EvaluationContext(), - new FlagEvaluationOptions(new[] { hook1.Object, hook2.Object }, new Dictionary())); - - hook1.Verify(x => x.Before(It.IsAny>(), It.IsAny>()), Times.Once); - hook2.Verify(x => x.Before(It.Is>(a => a.EvaluationContext.Get("test") == "test"), It.IsAny>()), Times.Once); - } - - [Fact] - [Specification("4.2.1", "`hook hints` MUST be a structure supports definition of arbitrary properties, with keys of type `string`, and values of type `boolean | string | number | datetime | structure`..")] - [Specification("4.2.2.1", "Condition: `Hook hints` MUST be immutable.")] - [Specification("4.2.2.2", "Condition: The client `metadata` field in the `hook context` MUST be immutable.")] - [Specification("4.2.2.3", "Condition: The provider `metadata` field in the `hook context` MUST be immutable")] - [Specification("4.3.1", "Hooks MUST specify at least one stage.")] - public async Task Hook_Should_Return_No_Errors() + ["string"] = "test", + ["number"] = 1, + ["boolean"] = true, + ["datetime"] = DateTime.Now, + ["structure"] = Structure.Empty + }; + var hookContext = new HookContext("test", false, FlagValueType.Boolean, + new ClientMetadata(null, null), new Metadata(null), EvaluationContext.Empty); + var evaluationDetails = + new FlagEvaluationDetails("test", false, ErrorType.None, "testing", "testing"); + + await hook.BeforeAsync(hookContext, hookHints); + await hook.AfterAsync(hookContext, evaluationDetails, hookHints); + await hook.FinallyAsync(hookContext, evaluationDetails, hookHints); + await hook.ErrorAsync(hookContext, new Exception(), hookHints); + + Assert.Null(hookContext.ClientMetadata.Name); + Assert.Null(hookContext.ClientMetadata.Version); + Assert.Null(hookContext.ProviderMetadata.Name); + } + + [Fact] + [Specification("4.3.5", "The `after` stage MUST run after flag resolution occurs. It accepts a `hook context` (required), `flag evaluation details` (required) and `hook hints` (optional). It has no return value.")] + [Specification("4.3.6", "The `error` hook MUST run when errors are encountered in the `before` stage, the `after` stage or during flag resolution. It accepts `hook context` (required), `exception` representing what went wrong (required), and `hook hints` (optional). It has no return value.")] + [Specification("4.3.7", "The `finally` hook MUST run after the `before`, `after`, and `error` stages. It accepts a `hook context` (required) and `hook hints` (optional). There is no return value.")] + [Specification("4.5.1", "`Flag evaluation options` MAY contain `hook hints`, a map of data to be provided to hook invocations.")] + [Specification("4.5.2", "`hook hints` MUST be passed to each hook.")] + [Specification("4.5.3", "The hook MUST NOT alter the `hook hints` structure.")] + public async Task Hook_Should_Execute_In_Correct_Order() + { + var featureProvider = Substitute.For(); + var hook = Substitute.For(); + + featureProvider.GetMetadata().Returns(new Metadata(null)); + featureProvider.GetProviderHooks().Returns(ImmutableList.Empty); + + // Sequence + hook.BeforeAsync(Arg.Any>(), Arg.Any>()).Returns(EvaluationContext.Empty); + featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new ResolutionDetails("test", false)); + _ = hook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + + await Api.Instance.SetProviderAsync(featureProvider); + var client = Api.Instance.GetClient(); + client.AddHooks(hook); + + await client.GetBooleanValueAsync("test", false); + + Received.InOrder(() => { - var hook = new TestHookNoOverride(); - var hookHints = new Dictionary - { - ["string"] = "test", - ["number"] = 1, - ["boolean"] = true, - ["datetime"] = DateTime.Now, - ["structure"] = new TestStructure() - }; - var hookContext = new HookContext("test", false, FlagValueType.Boolean, - new ClientMetadata(null, null), new Metadata(null), new EvaluationContext()); - - await hook.Before(hookContext, hookHints); - await hook.After(hookContext, new FlagEvaluationDetails("test", false, ErrorType.None, "testing", "testing"), hookHints); - await hook.Finally(hookContext, hookHints); - await hook.Error(hookContext, new Exception(), hookHints); - - hookContext.ClientMetadata.Name.Should().BeNull(); - hookContext.ClientMetadata.Version.Should().BeNull(); - hookContext.ProviderMetadata.Name.Should().BeNull(); - } - - [Fact] - [Specification("4.3.5", "The `after` stage MUST run after flag resolution occurs. It accepts a `hook context` (required), `flag evaluation details` (required) and `hook hints` (optional). It has no return value.")] - [Specification("4.3.6", "The `error` hook MUST run when errors are encountered in the `before` stage, the `after` stage or during flag resolution. It accepts `hook context` (required), `exception` representing what went wrong (required), and `hook hints` (optional). It has no return value.")] - [Specification("4.3.7", "The `finally` hook MUST run after the `before`, `after`, and `error` stages. It accepts a `hook context` (required) and `hook hints` (optional). There is no return value.")] - [Specification("4.5.1", "`Flag evaluation options` MAY contain `hook hints`, a map of data to be provided to hook invocations.")] - [Specification("4.5.2", "`hook hints` MUST be passed to each hook.")] - [Specification("4.5.3", "The hook MUST NOT alter the `hook hints` structure.")] - public async Task Hook_Should_Execute_In_Correct_Order() + hook.BeforeAsync(Arg.Any>(), Arg.Any>()); + featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()); + hook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + }); + + _ = hook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); + _ = hook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = featureProvider.Received(1).ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + [Specification("4.4.1", "The API, Client, Provider, and invocation MUST have a method for registering hooks.")] + public async Task Register_Hooks_Should_Be_Available_At_All_Levels() + { + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); + var hook3 = Substitute.For(); + var hook4 = Substitute.For(); + + var testProvider = new TestProvider(); + testProvider.AddHook(hook4); + Api.Instance.AddHooks(hook1); + await Api.Instance.SetProviderAsync(testProvider); + var client = Api.Instance.GetClient(); + client.AddHooks(hook2); + await client.GetBooleanValueAsync("test", false, null, + new FlagEvaluationOptions(hook3, ImmutableDictionary.Empty)); + + Assert.Single(Api.Instance.GetHooks()); + Assert.Single(client.GetHooks()); + Assert.Single(testProvider.GetProviderHooks()); + } + + [Fact] + [Specification("4.4.3", "If a `finally` hook abnormally terminates, evaluation MUST proceed, including the execution of any remaining `finally` hooks.")] + public async Task Finally_Hook_Should_Be_Executed_Even_If_Abnormal_Termination() + { + var featureProvider = Substitute.For(); + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); + + featureProvider.GetMetadata().Returns(new Metadata(null)); + featureProvider.GetProviderHooks().Returns(ImmutableList.Empty); + + // Sequence + hook1.BeforeAsync(Arg.Any>(), null).Returns(EvaluationContext.Empty); + hook2.BeforeAsync(Arg.Any>(), null).Returns(EvaluationContext.Empty); + featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new ResolutionDetails("test", false)); + hook2.AfterAsync(Arg.Any>(), Arg.Any>(), null).Returns(new ValueTask()); + hook1.AfterAsync(Arg.Any>(), Arg.Any>(), null).Returns(new ValueTask()); + hook2.FinallyAsync(Arg.Any>(), Arg.Any>(), null).Returns(new ValueTask()); + hook1.FinallyAsync(Arg.Any>(), Arg.Any>(), null).Throws(new Exception()); + + await Api.Instance.SetProviderAsync(featureProvider); + var client = Api.Instance.GetClient(); + client.AddHooks(new[] { hook1, hook2 }); + Assert.Equal(2, client.GetHooks().Count()); + + await client.GetBooleanValueAsync("test", false); + + Received.InOrder(() => { - var featureProvider = new Mock(); - var hook = new Mock(); + hook1.BeforeAsync(Arg.Any>(), null); + hook2.BeforeAsync(Arg.Any>(), null); + featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()); + hook2.AfterAsync(Arg.Any>(), Arg.Any>(), null); + hook1.AfterAsync(Arg.Any>(), Arg.Any>(), null); + hook2.FinallyAsync(Arg.Any>(), Arg.Any>(), null); + hook1.FinallyAsync(Arg.Any>(), Arg.Any>(), null); + }); + + _ = hook1.Received(1).BeforeAsync(Arg.Any>(), null); + _ = hook2.Received(1).BeforeAsync(Arg.Any>(), null); + _ = featureProvider.Received(1).ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()); + _ = hook2.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), null); + _ = hook1.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), null); + _ = hook2.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), null); + _ = hook1.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), null); + } - var sequence = new MockSequence(); + [Fact] + [Specification("4.4.4", "If an `error` hook abnormally terminates, evaluation MUST proceed, including the execution of any remaining `error` hooks.")] + public async Task Error_Hook_Should_Be_Executed_Even_If_Abnormal_Termination() + { + var featureProvider1 = Substitute.For(); + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); - featureProvider.Setup(x => x.GetMetadata()) - .Returns(new Metadata(null)); + featureProvider1.GetMetadata().Returns(new Metadata(null)); + featureProvider1.GetProviderHooks().Returns(ImmutableList.Empty); - hook.InSequence(sequence).Setup(x => - x.Before(It.IsAny>(), It.IsAny>())) - .ReturnsAsync(new EvaluationContext()); + // Sequence + hook1.BeforeAsync(Arg.Any>(), null).Returns(EvaluationContext.Empty); + hook2.BeforeAsync(Arg.Any>(), null).Returns(EvaluationContext.Empty); + featureProvider1.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()).Throws(new Exception()); + hook2.ErrorAsync(Arg.Any>(), Arg.Any(), null).Throws(new Exception()); + hook1.ErrorAsync(Arg.Any>(), Arg.Any(), null).Returns(new ValueTask()); - featureProvider.InSequence(sequence) - .Setup(x => x.ResolveBooleanValue(It.IsAny(), It.IsAny(), It.IsAny(), null)) - .ReturnsAsync(new ResolutionDetails("test", false)); + await Api.Instance.SetProviderAsync(featureProvider1); + var client = Api.Instance.GetClient(); + client.AddHooks(new[] { hook1, hook2 }); - hook.InSequence(sequence).Setup(x => x.After(It.IsAny>(), - It.IsAny>(), It.IsAny>())); + await client.GetBooleanValueAsync("test", false); - hook.InSequence(sequence).Setup(x => - x.Finally(It.IsAny>(), It.IsAny>())); + Received.InOrder(() => + { + hook1.BeforeAsync(Arg.Any>(), null); + hook2.BeforeAsync(Arg.Any>(), null); + featureProvider1.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()); + hook2.ErrorAsync(Arg.Any>(), Arg.Any(), null); + hook1.ErrorAsync(Arg.Any>(), Arg.Any(), null); + }); + + _ = hook1.Received(1).BeforeAsync(Arg.Any>(), null); + _ = hook2.Received(1).BeforeAsync(Arg.Any>(), null); + _ = hook1.Received(1).ErrorAsync(Arg.Any>(), Arg.Any(), null); + _ = hook2.Received(1).ErrorAsync(Arg.Any>(), Arg.Any(), null); + } + + [Fact] + [Specification("4.4.6", "If an error occurs during the evaluation of `before` or `after` hooks, any remaining hooks in the `before` or `after` stages MUST NOT be invoked.")] + public async Task Error_Occurs_During_Before_After_Evaluation_Should_Not_Invoke_Any_Remaining_Hooks() + { + var featureProvider = Substitute.For(); + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); - OpenFeature.Instance.SetProvider(featureProvider.Object); - var client = OpenFeature.Instance.GetClient(); - client.AddHooks(hook.Object); + featureProvider.GetMetadata().Returns(new Metadata(null)); + featureProvider.GetProviderHooks().Returns(ImmutableList.Empty); - await client.GetBooleanValue("test", false); + // Sequence + hook1.BeforeAsync(Arg.Any>(), Arg.Any>()).Throws(new Exception()); + _ = hook1.ErrorAsync(Arg.Any>(), Arg.Any(), null); + _ = hook2.ErrorAsync(Arg.Any>(), Arg.Any(), null); - hook.Verify(x => x.Before(It.IsAny>(), It.IsAny>()), Times.Once); - hook.Verify(x => x.After(It.IsAny>(), It.IsAny>(), It.IsAny>()), Times.Once); - hook.Verify(x => x.Finally(It.IsAny>(), It.IsAny>()), Times.Once); - featureProvider.Verify(x => x.ResolveBooleanValue(It.IsAny(), It.IsAny(), It.IsAny(), null), Times.Once); + await Api.Instance.SetProviderAsync(featureProvider); + var client = Api.Instance.GetClient(); + client.AddHooks(new[] { hook1, hook2 }); - } + await client.GetBooleanValueAsync("test", false); - [Fact] - [Specification("4.4.1", "The API, Client and invocation MUST have a method for registering hooks which accepts `flag evaluation options`")] - public async Task Register_Hooks_Should_Be_Available_At_All_Levels() - { - var hook1 = new Mock(); - var hook2 = new Mock(); - var hook3 = new Mock(); - - OpenFeature.Instance.AddHooks(hook1.Object); - var client = OpenFeature.Instance.GetClient(); - client.AddHooks(hook2.Object); - await client.GetBooleanValue("test", false, null, - new FlagEvaluationOptions(hook3.Object, new Dictionary())); - - client.ClearHooks(); - } - - [Fact] - [Specification("4.4.3", "If a `finally` hook abnormally terminates, evaluation MUST proceed, including the execution of any remaining `finally` hooks.")] - public async Task Finally_Hook_Should_Be_Executed_Even_If_Abnormal_Termination() + Received.InOrder(() => { - var featureProvider = new Mock(); - var hook1 = new Mock(); - var hook2 = new Mock(); + hook1.BeforeAsync(Arg.Any>(), Arg.Any>()); + hook2.ErrorAsync(Arg.Any>(), Arg.Any(), null); + hook1.ErrorAsync(Arg.Any>(), Arg.Any(), null); + }); + + _ = hook1.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); + _ = hook2.DidNotReceive().BeforeAsync(Arg.Any>(), Arg.Any>()); + _ = hook1.Received(1).ErrorAsync(Arg.Any>(), Arg.Any(), null); + _ = hook2.Received(1).ErrorAsync(Arg.Any>(), Arg.Any(), null); + } - var sequence = new MockSequence(); + [Fact] + [Specification("4.5.1", "`Flag evaluation options` MAY contain `hook hints`, a map of data to be provided to hook invocations.")] + public async Task Hook_Hints_May_Be_Optional() + { + var featureProvider = Substitute.For(); + var hook = Substitute.For(); + var flagOptions = new FlagEvaluationOptions(hook); - featureProvider.Setup(x => x.GetMetadata()) - .Returns(new Metadata(null)); + featureProvider.GetMetadata() + .Returns(new Metadata(null)); - hook1.InSequence(sequence).Setup(x => - x.Before(It.IsAny>(), It.IsAny>())) - .ReturnsAsync(new EvaluationContext()); + featureProvider.GetProviderHooks() + .Returns(ImmutableList.Empty); - hook2.InSequence(sequence).Setup(x => - x.Before(It.IsAny>(), It.IsAny>())) - .ReturnsAsync(new EvaluationContext()); + hook.BeforeAsync(Arg.Any>(), Arg.Any>()) + .Returns(EvaluationContext.Empty); - featureProvider.InSequence(sequence) - .Setup(x => x.ResolveBooleanValue(It.IsAny(), It.IsAny(), It.IsAny(), - null)) - .ReturnsAsync(new ResolutionDetails("test", false)); + featureProvider.ResolveBooleanValueAsync("test", false, Arg.Any()) + .Returns(new ResolutionDetails("test", false)); - hook1.InSequence(sequence).Setup(x => x.After(It.IsAny>(), - It.IsAny>(), It.IsAny>())); + hook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()) + .Returns(new ValueTask()); - hook2.InSequence(sequence).Setup(x => x.After(It.IsAny>(), - It.IsAny>(), It.IsAny>())); + hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()) + .Returns(new ValueTask()); - hook1.Setup(x => - x.Finally(It.IsAny>(), It.IsAny>())) - .Throws(new Exception()); + await Api.Instance.SetProviderAsync(featureProvider); + var client = Api.Instance.GetClient(); - hook2.InSequence(sequence).Setup(x => - x.Finally(It.IsAny>(), It.IsAny>())); + await client.GetBooleanValueAsync("test", false, EvaluationContext.Empty, flagOptions); - OpenFeature.Instance.SetProvider(featureProvider.Object); - var client = OpenFeature.Instance.GetClient(); - client.AddHooks(new[] { hook1.Object, hook2.Object }); + Received.InOrder(() => + { + hook.Received().BeforeAsync(Arg.Any>(), Arg.Any>()); + featureProvider.Received().ResolveBooleanValueAsync("test", false, Arg.Any()); + hook.Received().AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + hook.Received().FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + }); + } + + [Fact] + [Specification("4.4.5", "If an error occurs in the `before` or `after` hooks, the `error` hooks MUST be invoked.")] + [Specification("4.4.7", "If an error occurs in the `before` hooks, the default value MUST be returned.")] + public async Task When_Error_Occurs_In_Before_Hook_Should_Return_Default_Value() + { + var featureProvider = Substitute.For(); + var hook = Substitute.For(); + var exceptionToThrow = new Exception("Fails during default"); - await client.GetBooleanValue("test", false); + featureProvider.GetMetadata().Returns(new Metadata(null)); - hook1.Verify(x => x.Before(It.IsAny>(), It.IsAny>()), Times.Once); - hook1.Verify(x => x.After(It.IsAny>(), It.IsAny>(), It.IsAny>()), Times.Once); - hook1.Verify(x => x.Finally(It.IsAny>(), It.IsAny>()), Times.Once); - hook2.Verify(x => x.Before(It.IsAny>(), It.IsAny>()), Times.Once); - hook2.Verify(x => x.After(It.IsAny>(), It.IsAny>(), It.IsAny>()), Times.Once); - hook2.Verify(x => x.Finally(It.IsAny>(), It.IsAny>()), Times.Once); - featureProvider.Verify(x => x.ResolveBooleanValue(It.IsAny(), It.IsAny(), It.IsAny(), null), Times.Once); - } + // Sequence + hook.BeforeAsync(Arg.Any>(), Arg.Any>()).Throws(exceptionToThrow); + hook.ErrorAsync(Arg.Any>(), Arg.Any(), null).Returns(new ValueTask()); + hook.FinallyAsync(Arg.Any>(), Arg.Any>(), null).Returns(new ValueTask()); - [Fact] - [Specification("4.4.4", "If a `finally` hook abnormally terminates, evaluation MUST proceed, including the execution of any remaining `finally` hooks.")] - public async Task Error_Hook_Should_Be_Executed_Even_If_Abnormal_Termination() + var client = Api.Instance.GetClient(); + client.AddHooks(hook); + + var resolvedFlag = await client.GetBooleanValueAsync("test", true); + + Received.InOrder(() => { - var featureProvider = new Mock(); - var hook1 = new Mock(); - var hook2 = new Mock(); + hook.BeforeAsync(Arg.Any>(), Arg.Any>()); + hook.ErrorAsync(Arg.Any>(), Arg.Any(), null); + hook.FinallyAsync(Arg.Any>(), Arg.Any>(), null); + }); + + Assert.True(resolvedFlag); + _ = hook.Received(1).BeforeAsync(Arg.Any>(), null); + _ = hook.Received(1).ErrorAsync(Arg.Any>(), exceptionToThrow, null); + _ = hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), null); + } - var sequence = new MockSequence(); + [Fact] + [Specification("4.4.5", "If an error occurs in the `before` or `after` hooks, the `error` hooks MUST be invoked.")] + public async Task When_Error_Occurs_In_After_Hook_Should_Invoke_Error_Hook() + { + var featureProvider = Substitute.For(); + var hook = Substitute.For(); + var flagOptions = new FlagEvaluationOptions(hook); + var exceptionToThrow = new Exception("Fails during default"); + + featureProvider.GetMetadata() + .Returns(new Metadata(null)); - featureProvider.Setup(x => x.GetMetadata()) - .Returns(new Metadata(null)); + featureProvider.GetProviderHooks() + .Returns(ImmutableList.Empty); - hook1.InSequence(sequence).Setup(x => - x.Before(It.IsAny>(), It.IsAny>())) - .ReturnsAsync(new EvaluationContext()); + hook.BeforeAsync(Arg.Any>(), Arg.Any>()) + .Returns(EvaluationContext.Empty); - hook2.InSequence(sequence).Setup(x => - x.Before(It.IsAny>(), It.IsAny>())) - .ReturnsAsync(new EvaluationContext()); + featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ResolutionDetails("test", false)); - featureProvider.InSequence(sequence) - .Setup(x => x.ResolveBooleanValue(It.IsAny(), It.IsAny(), It.IsAny(), - null)) - .Throws(new Exception()); + hook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()) + .Throws(exceptionToThrow); - hook1.InSequence(sequence).Setup(x => - x.Error(It.IsAny>(), It.IsAny(), null)) - .ThrowsAsync(new Exception()); + hook.ErrorAsync(Arg.Any>(), Arg.Any(), Arg.Any>()) + .Returns(new ValueTask()); - hook2.InSequence(sequence).Setup(x => - x.Error(It.IsAny>(), It.IsAny(), null)); + hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()) + .Returns(new ValueTask()); - OpenFeature.Instance.SetProvider(featureProvider.Object); - var client = OpenFeature.Instance.GetClient(); - client.AddHooks(new[] { hook1.Object, hook2.Object }); + await Api.Instance.SetProviderAsync(featureProvider); + var client = Api.Instance.GetClient(); - await client.GetBooleanValue("test", false); + var resolvedFlag = await client.GetBooleanValueAsync("test", true, config: flagOptions); - hook1.Verify(x => x.Before(It.IsAny>(), It.IsAny>()), Times.Once); - hook1.Verify(x => x.Error(It.IsAny>(), It.IsAny(), null), Times.Once); - hook2.Verify(x => x.Before(It.IsAny>(), It.IsAny>()), Times.Once); - hook2.Verify(x => x.Error(It.IsAny>(), It.IsAny(), null), Times.Once); - } + Assert.True(resolvedFlag); - [Fact] - [Specification("4.4.6", "If an error occurs during the evaluation of `before` or `after` hooks, any remaining hooks in the `before` or `after` stages MUST NOT be invoked.")] - public async Task Error_Occurs_During_Before_After_Evaluation_Should_Not_Invoke_Any_Remaining_Hooks() + Received.InOrder(() => { - var featureProvider = new Mock(); - var hook1 = new Mock(); - var hook2 = new Mock(); + hook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); + hook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + }); + + await featureProvider.DidNotReceive().ResolveBooleanValueAsync("test", false, Arg.Any()); + } + + [Fact] + public async Task Successful_Resolution_Should_Pass_Cancellation_Token() + { + var featureProvider = Substitute.For(); + var hook = Substitute.For(); + var cts = new CancellationTokenSource(); + + featureProvider.GetMetadata().Returns(new Metadata(null)); + featureProvider.GetProviderHooks().Returns(ImmutableList.Empty); + + hook.BeforeAsync(Arg.Any>(), Arg.Any>(), cts.Token).Returns(EvaluationContext.Empty); + featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any(), cts.Token).Returns(new ResolutionDetails("test", false)); + _ = hook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), cts.Token); + _ = hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), cts.Token); - var sequence = new MockSequence(); + await Api.Instance.SetProviderAsync(featureProvider); + var client = Api.Instance.GetClient(); + client.AddHooks(hook); - featureProvider.Setup(x => x.GetMetadata()) - .Returns(new Metadata(null)); + await client.GetBooleanValueAsync("test", false, EvaluationContext.Empty, null, cts.Token); - hook1.InSequence(sequence).Setup(x => - x.Before(It.IsAny>(), It.IsAny>())) - .ThrowsAsync(new Exception()); + _ = hook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>(), cts.Token); + _ = hook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), cts.Token); + _ = hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), cts.Token); + } + + [Fact] + public async Task Failed_Resolution_Should_Pass_Cancellation_Token() + { + var featureProvider = Substitute.For(); + var hook = Substitute.For(); + var flagOptions = new FlagEvaluationOptions(hook); + var exceptionToThrow = new GeneralException("Fake Exception"); + var cts = new CancellationTokenSource(); + + featureProvider.GetMetadata() + .Returns(new Metadata(null)); + + featureProvider.GetProviderHooks() + .Returns(ImmutableList.Empty); + + hook.BeforeAsync(Arg.Any>(), Arg.Any>()) + .Returns(EvaluationContext.Empty); - hook1.InSequence(sequence).Setup(x => - x.Error(It.IsAny>(), It.IsAny(), null)); + featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Throws(exceptionToThrow); - hook2.InSequence(sequence).Setup(x => - x.Error(It.IsAny>(), It.IsAny(), null)); + hook.ErrorAsync(Arg.Any>(), Arg.Any(), Arg.Any>()) + .Returns(new ValueTask()); - OpenFeature.Instance.SetProvider(featureProvider.Object); - var client = OpenFeature.Instance.GetClient(); - client.AddHooks(new[] { hook1.Object, hook2.Object }); + hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()) + .Returns(new ValueTask()); - await client.GetBooleanValue("test", false); + await Api.Instance.SetProviderAsync(featureProvider); + var client = Api.Instance.GetClient(); - hook1.Verify(x => x.Before(It.IsAny>(), It.IsAny>()), Times.Once); - hook2.Verify(x => x.Before(It.IsAny>(), It.IsAny>()), Times.Never); - hook1.Verify(x => x.Error(It.IsAny>(), It.IsAny(), null), Times.Once); - hook2.Verify(x => x.Error(It.IsAny>(), It.IsAny(), null), Times.Once); - } + await client.GetBooleanValueAsync("test", true, EvaluationContext.Empty, flagOptions, cts.Token); + + _ = hook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>(), cts.Token); + _ = hook.Received(1).ErrorAsync(Arg.Any>(), Arg.Any(), Arg.Any>(), cts.Token); + _ = hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), cts.Token); + + await featureProvider.DidNotReceive().ResolveBooleanValueAsync("test", false, Arg.Any()); + } + + [Fact] + public void Add_hooks_should_accept_empty_enumerable() + { + Api.Instance.ClearHooks(); + Api.Instance.AddHooks(Enumerable.Empty()); } } diff --git a/test/OpenFeature.Tests/OpenFeatureTests.cs b/test/OpenFeature.Tests/OpenFeatureTests.cs index 5c7cb768..6afcc91c 100644 --- a/test/OpenFeature.Tests/OpenFeatureTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureTests.cs @@ -1,100 +1,317 @@ using System; -using System.Collections.Generic; using System.Linq; -using AutoFixture; -using FluentAssertions; -using Microsoft.Extensions.Logging; -using Moq; -using OpenFeature.SDK.Constant; -using OpenFeature.SDK.Model; -using OpenFeature.SDK.Tests.Internal; +using System.Threading.Tasks; +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Tests.Internal; using Xunit; -namespace OpenFeature.SDK.Tests +namespace OpenFeature.Tests; + +public class OpenFeatureTests : ClearOpenFeatureInstanceFixture { - public class OpenFeatureTests - { - [Fact] - [Specification("1.1.1", "The API, and any state it maintains SHOULD exist as a global singleton, even in cases wherein multiple versions of the API are present at runtime.")] - public void OpenFeature_Should_Be_Singleton() - { - var openFeature = OpenFeature.Instance; - var openFeature2 = OpenFeature.Instance; - - openFeature.Should().BeSameAs(openFeature2); - } - - [Fact] - [Specification("1.1.3", "The API MUST provide a function to add hooks which accepts one or more API-conformant hooks, and appends them to the collection of any previously added hooks. When new hooks are added, previously added hooks are not removed.")] - public void OpenFeature_Should_Add_Hooks() - { - var openFeature = OpenFeature.Instance; - var hook1 = new Mock().Object; - var hook2 = new Mock().Object; - var hook3 = new Mock().Object; - var hook4 = new Mock().Object; - - openFeature.ClearHooks(); - - openFeature.AddHooks(hook1); - - openFeature.GetHooks().Should().Contain(hook1); - openFeature.GetHooks().Count.Should().Be(1); - - openFeature.AddHooks(hook2); - openFeature.GetHooks().Should().ContainInOrder(hook1, hook2); - openFeature.GetHooks().Count.Should().Be(2); - - openFeature.AddHooks(new[] { hook3, hook4 }); - openFeature.GetHooks().Should().ContainInOrder(hook1, hook2, hook3, hook4); - openFeature.GetHooks().Count.Should().Be(4); - - openFeature.ClearHooks(); - openFeature.GetHooks().Count.Should().Be(0); - } - - [Fact] - [Specification("1.1.4", "The API MUST provide a function for retrieving the metadata field of the configured `provider`.")] - public void OpenFeature_Should_Get_Metadata() - { - OpenFeature.Instance.SetProvider(new NoOpFeatureProvider()); - var openFeature = OpenFeature.Instance; - var metadata = openFeature.GetProviderMetadata(); - - metadata.Should().NotBeNull(); - metadata.Name.Should().Be(NoOpProvider.NoOpProviderName); - } - - [Theory] - [InlineData("client1", "version1")] - [InlineData("client2", null)] - [InlineData(null, null)] - [Specification("1.1.5", "The `API` MUST provide a function for creating a `client` which accepts the following options: - name (optional): A logical string identifier for the client.")] - public void OpenFeature_Should_Create_Client(string name = null, string version = null) - { - var openFeature = OpenFeature.Instance; - var client = openFeature.GetClient(name, version); - - client.Should().NotBeNull(); - client.GetMetadata().Name.Should().Be(name); - client.GetMetadata().Version.Should().Be(version); - } - - [Fact] - public void Should_Set_Given_Context() - { - var fixture = new Fixture(); - var context = fixture.Create(); - - OpenFeature.Instance.SetContext(context); - - OpenFeature.Instance.GetContext().Should().BeSameAs(context); - } - - [Fact] - public void Should_Always_Have_Provider() - { - OpenFeature.Instance.GetProvider().Should().NotBeNull(); - } + [Fact] + [Specification("1.1.1", "The `API`, and any state it maintains SHOULD exist as a global singleton, even in cases wherein multiple versions of the `API` are present at runtime.")] + public void OpenFeature_Should_Be_Singleton() + { + var openFeature = Api.Instance; + var openFeature2 = Api.Instance; + + Assert.Equal(openFeature2, openFeature); + } + + [Fact] + [Specification("1.1.2.2", "The provider mutator function MUST invoke the initialize function on the newly registered provider before using it to resolve flag values.")] + public async Task OpenFeature_Should_Initialize_Provider() + { + var providerMockDefault = Substitute.For(); + providerMockDefault.Status.Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProviderAsync(providerMockDefault); + await providerMockDefault.Received(1).InitializeAsync(Api.Instance.GetContext()); + + var providerMockNamed = Substitute.For(); + providerMockNamed.Status.Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProviderAsync("the-name", providerMockNamed); + await providerMockNamed.Received(1).InitializeAsync(Api.Instance.GetContext()); + } + + [Fact] + [Specification("1.1.2.3", + "The provider mutator function MUST invoke the shutdown function on the previously registered provider once it's no longer being used to resolve flag values.")] + public async Task OpenFeature_Should_Shutdown_Unused_Provider() + { + var providerA = Substitute.For(); + providerA.Status.Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProviderAsync(providerA); + await providerA.Received(1).InitializeAsync(Api.Instance.GetContext()); + + var providerB = Substitute.For(); + providerB.Status.Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProviderAsync(providerB); + await providerB.Received(1).InitializeAsync(Api.Instance.GetContext()); + await providerA.Received(1).ShutdownAsync(); + + var providerC = Substitute.For(); + providerC.Status.Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProviderAsync("named", providerC); + await providerC.Received(1).InitializeAsync(Api.Instance.GetContext()); + + var providerD = Substitute.For(); + providerD.Status.Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProviderAsync("named", providerD); + await providerD.Received(1).InitializeAsync(Api.Instance.GetContext()); + await providerC.Received(1).ShutdownAsync(); + } + + [Fact] + [Specification("1.6.1", "The API MUST define a mechanism to propagate a shutdown request to active providers.")] + public async Task OpenFeature_Should_Support_Shutdown() + { + var providerA = Substitute.For(); + providerA.Status.Returns(ProviderStatus.NotReady); + + var providerB = Substitute.For(); + providerB.Status.Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProviderAsync(providerA); + await Api.Instance.SetProviderAsync("named", providerB); + + await Api.Instance.ShutdownAsync(); + + await providerA.Received(1).ShutdownAsync(); + await providerB.Received(1).ShutdownAsync(); + } + + [Fact] + [Specification("1.1.3", "The `API` MUST provide a function to bind a given `provider` to one or more client `name`s. If the client-name already has a bound provider, it is overwritten with the new mapping.")] + public async Task OpenFeature_Should_Not_Change_Named_Providers_When_Setting_Default_Provider() + { + var openFeature = Api.Instance; + + await openFeature.SetProviderAsync(new NoOpFeatureProvider()); + await openFeature.SetProviderAsync(TestProvider.DefaultName, new TestProvider()); + + var defaultClient = openFeature.GetProviderMetadata(); + var domainScopedClient = openFeature.GetProviderMetadata(TestProvider.DefaultName); + + Assert.Equal(NoOpProvider.NoOpProviderName, defaultClient?.Name); + Assert.Equal(TestProvider.DefaultName, domainScopedClient?.Name); + } + + [Fact] + [Specification("1.1.3", "The `API` MUST provide a function to bind a given `provider` to one or more client `name`s. If the client-name already has a bound provider, it is overwritten with the new mapping.")] + public async Task OpenFeature_Should_Set_Default_Provide_When_No_Name_Provided() + { + var openFeature = Api.Instance; + + await openFeature.SetProviderAsync(new TestProvider()); + + var defaultClient = openFeature.GetProviderMetadata(); + + Assert.Equal(TestProvider.DefaultName, defaultClient?.Name); + } + + [Fact] + [Specification("1.1.3", "The `API` MUST provide a function to bind a given `provider` to one or more client `name`s. If the client-name already has a bound provider, it is overwritten with the new mapping.")] + public async Task OpenFeature_Should_Assign_Provider_To_Existing_Client() + { + const string name = "new-client"; + var openFeature = Api.Instance; + + await openFeature.SetProviderAsync(name, new TestProvider()); + await openFeature.SetProviderAsync(name, new NoOpFeatureProvider()); + + Assert.Equal(NoOpProvider.NoOpProviderName, openFeature.GetProviderMetadata(name)?.Name); + } + + [Fact] + [Specification("1.1.3", "The `API` MUST provide a function to bind a given `provider` to one or more client `name`s. If the client-name already has a bound provider, it is overwritten with the new mapping.")] + public async Task OpenFeature_Should_Allow_Multiple_Client_Names_Of_Same_Instance() + { + var openFeature = Api.Instance; + var provider = new TestProvider(); + + await openFeature.SetProviderAsync("a", provider); + await openFeature.SetProviderAsync("b", provider); + + var clientA = openFeature.GetProvider("a"); + var clientB = openFeature.GetProvider("b"); + + Assert.Equal(clientB, clientA); + } + + [Fact] + [Specification("1.1.4", "The `API` MUST provide a function to add `hooks` which accepts one or more API-conformant `hooks`, and appends them to the collection of any previously added hooks. When new hooks are added, previously added hooks are not removed.")] + public void OpenFeature_Should_Add_Hooks() + { + var openFeature = Api.Instance; + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); + var hook3 = Substitute.For(); + var hook4 = Substitute.For(); + + openFeature.ClearHooks(); + + openFeature.AddHooks(hook1); + + Assert.Contains(hook1, openFeature.GetHooks()); + Assert.Single(openFeature.GetHooks()); + + openFeature.AddHooks(hook2); + var expectedHooks = new[] { hook1, hook2 }.AsEnumerable(); + Assert.Equal(expectedHooks, openFeature.GetHooks()); + + openFeature.AddHooks(new[] { hook3, hook4 }); + expectedHooks = new[] { hook1, hook2, hook3, hook4 }.AsEnumerable(); + Assert.Equal(expectedHooks, openFeature.GetHooks()); + + openFeature.ClearHooks(); + Assert.Empty(openFeature.GetHooks()); + } + + [Fact] + [Specification("1.1.5", "The API MUST provide a function for retrieving the metadata field of the configured `provider`.")] + public async Task OpenFeature_Should_Get_Metadata() + { + await Api.Instance.SetProviderAsync(new NoOpFeatureProvider()); + var openFeature = Api.Instance; + var metadata = openFeature.GetProviderMetadata(); + + Assert.NotNull(metadata); + Assert.Equal(NoOpProvider.NoOpProviderName, metadata?.Name); + } + + [Theory] + [InlineData("client1", "version1")] + [InlineData("client2", null)] + [InlineData(null, null)] + [Specification("1.1.6", "The `API` MUST provide a function for creating a `client` which accepts the following options: - name (optional): A logical string identifier for the client.")] + public void OpenFeature_Should_Create_Client(string? name = null, string? version = null) + { + var openFeature = Api.Instance; + var client = openFeature.GetClient(name, version); + + Assert.NotNull(client); + Assert.Equal(name, client.GetMetadata().Name); + Assert.Equal(version, client.GetMetadata().Version); + } + + [Fact] + public void Should_Set_Given_Context() + { + var context = EvaluationContext.Empty; + + Api.Instance.SetContext(context); + + Assert.Equal(context, Api.Instance.GetContext()); + + context = EvaluationContext.Builder().Build(); + + Api.Instance.SetContext(context); + + Assert.Equal(context, Api.Instance.GetContext()); + } + + [Fact] + public void Should_Always_Have_Provider() + { + Assert.NotNull(Api.Instance.GetProvider()); + } + + [Fact] + public async Task OpenFeature_Should_Allow_Multiple_Client_Mapping() + { + var openFeature = Api.Instance; + + await openFeature.SetProviderAsync("client1", new TestProvider()); + await openFeature.SetProviderAsync("client2", new NoOpFeatureProvider()); + + var client1 = openFeature.GetClient("client1"); + var client2 = openFeature.GetClient("client2"); + + Assert.Equal("client1", client1.GetMetadata().Name); + Assert.Equal("client2", client2.GetMetadata().Name); + + Assert.True(await client1.GetBooleanValueAsync("test", false)); + Assert.False(await client2.GetBooleanValueAsync("test", false)); + } + + [Fact] + public void SetTransactionContextPropagator_ShouldThrowArgumentNullException_WhenNullPropagatorIsPassed() + { + // Arrange + var api = Api.Instance; + + // Act & Assert + Assert.Throws(() => api.SetTransactionContextPropagator(null!)); + } + + [Fact] + public void SetTransactionContextPropagator_ShouldSetPropagator_WhenValidPropagatorIsPassed() + { + // Arrange + var api = Api.Instance; + var mockPropagator = Substitute.For(); + + // Act + api.SetTransactionContextPropagator(mockPropagator); + + // Assert + Assert.Equal(mockPropagator, api.GetTransactionContextPropagator()); + } + + [Fact] + public void SetTransactionContext_ShouldThrowArgumentNullException_WhenEvaluationContextIsNull() + { + // Arrange + var api = Api.Instance; + + // Act & Assert + Assert.Throws(() => api.SetTransactionContext(null!)); + } + + [Fact] + public void SetTransactionContext_ShouldSetTransactionContext_WhenValidEvaluationContextIsProvided() + { + // Arrange + var api = Api.Instance; + var evaluationContext = EvaluationContext.Builder() + .Set("initial", "yes") + .Build(); + var mockPropagator = Substitute.For(); + mockPropagator.GetTransactionContext().Returns(evaluationContext); + api.SetTransactionContextPropagator(mockPropagator); + api.SetTransactionContext(evaluationContext); + + // Act + api.SetTransactionContext(evaluationContext); + var result = api.GetTransactionContext(); + + // Assert + mockPropagator.Received().SetTransactionContext(evaluationContext); + Assert.Equal(evaluationContext, result); + Assert.Equal(evaluationContext.GetValue("initial"), result.GetValue("initial")); + } + + [Fact] + public void GetTransactionContext_ShouldReturnEmptyEvaluationContext_WhenNoPropagatorIsSet() + { + // Arrange + var api = Api.Instance; + var context = EvaluationContext.Builder().Set("status", "not-ready").Build(); + api.SetTransactionContext(context); + + // Act + var result = api.GetTransactionContext(); + + // Assert + Assert.Equal(EvaluationContext.Empty, result); } } diff --git a/test/OpenFeature.Tests/ProviderRepositoryTests.cs b/test/OpenFeature.Tests/ProviderRepositoryTests.cs new file mode 100644 index 00000000..16fb5d13 --- /dev/null +++ b/test/OpenFeature.Tests/ProviderRepositoryTests.cs @@ -0,0 +1,412 @@ +using System; +using System.Threading.Tasks; +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.Model; +using Xunit; + +// We intentionally do not await for purposes of validating behavior. +#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed + +namespace OpenFeature.Tests; + +public class ProviderRepositoryTests +{ + [Fact] + public async Task Default_Provider_Is_Set_Without_Await() + { + var repository = new ProviderRepository(); + var provider = new NoOpFeatureProvider(); + var context = new EvaluationContextBuilder().Build(); + await repository.SetProviderAsync(provider, context); + Assert.Equal(provider, repository.GetProvider()); + } + + [Fact] + public async Task Initialization_Provider_Method_Is_Invoked_For_Setting_Default_Provider() + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + await repository.SetProviderAsync(providerMock, context); + providerMock.Received(1).InitializeAsync(context); + providerMock.DidNotReceive().ShutdownAsync(); + } + + [Fact] + public async Task AfterInitialization_Is_Invoked_For_Setting_Default_Provider() + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + var callCount = 0; + await repository.SetProviderAsync(providerMock, context, afterInitSuccess: (theProvider) => + { + Assert.Equal(providerMock, theProvider); + callCount++; + return Task.CompletedTask; + }); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task AfterError_Is_Invoked_If_Initialization_Errors_Default_Provider() + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + providerMock.When(x => x.InitializeAsync(context)).Throw(new Exception("BAD THINGS")); + var callCount = 0; + Exception? receivedError = null; + await repository.SetProviderAsync(providerMock, context, afterInitError: (theProvider, error) => + { + Assert.Equal(providerMock, theProvider); + callCount++; + receivedError = error; + return Task.CompletedTask; + }); + Assert.Equal("BAD THINGS", receivedError?.Message); + Assert.Equal(1, callCount); + } + + [Theory] + [InlineData(ProviderStatus.Ready)] + [InlineData(ProviderStatus.Stale)] + [InlineData(ProviderStatus.Error)] + internal async Task Initialize_Is_Not_Called_For_Ready_Provider(ProviderStatus status) + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(status); + var context = new EvaluationContextBuilder().Build(); + await repository.SetProviderAsync(providerMock, context); + providerMock.DidNotReceive().InitializeAsync(context); + } + + [Theory] + [InlineData(ProviderStatus.Ready)] + [InlineData(ProviderStatus.Stale)] + [InlineData(ProviderStatus.Error)] + internal async Task AfterInitialize_Is_Not_Called_For_Ready_Provider(ProviderStatus status) + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(status); + var context = new EvaluationContextBuilder().Build(); + var callCount = 0; + await repository.SetProviderAsync(providerMock, context, afterInitSuccess: provider => + { + callCount++; + return Task.CompletedTask; + }); + Assert.Equal(0, callCount); + } + + [Fact] + public async Task Replaced_Default_Provider_Is_Shutdown() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.Status.Returns(ProviderStatus.NotReady); + + var provider2 = Substitute.For(); + provider2.Status.Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + await repository.SetProviderAsync(provider1, context); + await repository.SetProviderAsync(provider2, context); + provider1.Received(1).ShutdownAsync(); + provider2.DidNotReceive().ShutdownAsync(); + } + + [Fact] + public async Task Named_Provider_Provider_Is_Set_Without_Await() + { + var repository = new ProviderRepository(); + var provider = new NoOpFeatureProvider(); + var context = new EvaluationContextBuilder().Build(); + + await repository.SetProviderAsync("the-name", provider, context); + Assert.Equal(provider, repository.GetProvider("the-name")); + } + + [Fact] + public async Task Initialization_Provider_Method_Is_Invoked_For_Setting_Named_Provider() + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + await repository.SetProviderAsync("the-name", providerMock, context); + providerMock.Received(1).InitializeAsync(context); + providerMock.DidNotReceive().ShutdownAsync(); + } + + [Fact] + public async Task AfterInitialization_Is_Invoked_For_Setting_Named_Provider() + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + var callCount = 0; + await repository.SetProviderAsync("the-name", providerMock, context, afterInitSuccess: (theProvider) => + { + Assert.Equal(providerMock, theProvider); + callCount++; + return Task.CompletedTask; + }); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task AfterError_Is_Invoked_If_Initialization_Errors_Named_Provider() + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + providerMock.When(x => x.InitializeAsync(context)).Throw(new Exception("BAD THINGS")); + var callCount = 0; + Exception? receivedError = null; + await repository.SetProviderAsync("the-provider", providerMock, context, afterInitError: (theProvider, error) => + { + Assert.Equal(providerMock, theProvider); + callCount++; + receivedError = error; + return Task.CompletedTask; + }); + Assert.Equal("BAD THINGS", receivedError?.Message); + Assert.Equal(1, callCount); + } + + [Theory] + [InlineData(ProviderStatus.Ready)] + [InlineData(ProviderStatus.Stale)] + [InlineData(ProviderStatus.Error)] + internal async Task Initialize_Is_Not_Called_For_Ready_Named_Provider(ProviderStatus status) + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(status); + var context = new EvaluationContextBuilder().Build(); + await repository.SetProviderAsync("the-name", providerMock, context); + providerMock.DidNotReceive().InitializeAsync(context); + } + + [Theory] + [InlineData(ProviderStatus.Ready)] + [InlineData(ProviderStatus.Stale)] + [InlineData(ProviderStatus.Error)] + internal async Task AfterInitialize_Is_Not_Called_For_Ready_Named_Provider(ProviderStatus status) + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(status); + var context = new EvaluationContextBuilder().Build(); + var callCount = 0; + await repository.SetProviderAsync("the-name", providerMock, context, + afterInitSuccess: provider => + { + callCount++; + return Task.CompletedTask; + }); + Assert.Equal(0, callCount); + } + + [Fact] + public async Task Replaced_Named_Provider_Is_Shutdown() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.Status.Returns(ProviderStatus.NotReady); + + var provider2 = Substitute.For(); + provider2.Status.Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + await repository.SetProviderAsync("the-name", provider1, context); + await repository.SetProviderAsync("the-name", provider2, context); + provider1.Received(1).ShutdownAsync(); + provider2.DidNotReceive().ShutdownAsync(); + } + + [Fact] + public async Task In_Use_Provider_Named_And_Default_Is_Not_Shutdown() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.Status.Returns(ProviderStatus.NotReady); + + var provider2 = Substitute.For(); + provider2.Status.Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + + await repository.SetProviderAsync(provider1, context); + await repository.SetProviderAsync("A", provider1, context); + // Provider one is replaced for "A", but not default. + await repository.SetProviderAsync("A", provider2, context); + + provider1.DidNotReceive().ShutdownAsync(); + } + + [Fact] + public async Task In_Use_Provider_Two_Named_Is_Not_Shutdown() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.Status.Returns(ProviderStatus.NotReady); + + var provider2 = Substitute.For(); + provider2.Status.Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + + await repository.SetProviderAsync("B", provider1, context); + await repository.SetProviderAsync("A", provider1, context); + // Provider one is replaced for "A", but not "B". + await repository.SetProviderAsync("A", provider2, context); + + provider1.DidNotReceive().ShutdownAsync(); + } + + [Fact] + public async Task When_All_Instances_Are_Removed_Shutdown_Is_Called() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.Status.Returns(ProviderStatus.NotReady); + + var provider2 = Substitute.For(); + provider2.Status.Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + + await repository.SetProviderAsync("B", provider1, context); + await repository.SetProviderAsync("A", provider1, context); + + await repository.SetProviderAsync("A", provider2, context); + await repository.SetProviderAsync("B", provider2, context); + + provider1.Received(1).ShutdownAsync(); + } + + [Fact] + public async Task Can_Get_Providers_By_Name() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.Status.Returns(ProviderStatus.NotReady); + + var provider2 = Substitute.For(); + provider2.Status.Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + + await repository.SetProviderAsync("A", provider1, context); + await repository.SetProviderAsync("B", provider2, context); + + Assert.Equal(provider1, repository.GetProvider("A")); + Assert.Equal(provider2, repository.GetProvider("B")); + } + + [Fact] + public async Task Replaced_Named_Provider_Gets_Latest_Set() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.Status.Returns(ProviderStatus.NotReady); + + var provider2 = Substitute.For(); + provider2.Status.Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + + await repository.SetProviderAsync("A", provider1, context); + await repository.SetProviderAsync("A", provider2, context); + + Assert.Equal(provider2, repository.GetProvider("A")); + } + + [Fact] + public async Task Can_Shutdown_All_Providers() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.Status.Returns(ProviderStatus.NotReady); + + var provider2 = Substitute.For(); + provider2.Status.Returns(ProviderStatus.NotReady); + + var provider3 = Substitute.For(); + provider3.Status.Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + + await repository.SetProviderAsync(provider1, context); + await repository.SetProviderAsync("provider1", provider1, context); + await repository.SetProviderAsync("provider2", provider2, context); + await repository.SetProviderAsync("provider2a", provider2, context); + await repository.SetProviderAsync("provider3", provider3, context); + + await repository.ShutdownAsync(); + + provider1.Received(1).ShutdownAsync(); + provider2.Received(1).ShutdownAsync(); + provider3.Received(1).ShutdownAsync(); + } + + [Fact] + public async Task Setting_Same_Default_Provider_Has_No_Effect() + { + var repository = new ProviderRepository(); + var provider = Substitute.For(); + provider.Status.Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + await repository.SetProviderAsync(provider, context); + await repository.SetProviderAsync(provider, context); + + Assert.Equal(provider, repository.GetProvider()); + provider.Received(1).InitializeAsync(context); + provider.DidNotReceive().ShutdownAsync(); + } + + [Fact] + public async Task Setting_Null_Default_Provider_Has_No_Effect() + { + var repository = new ProviderRepository(); + var provider = Substitute.For(); + provider.Status.Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + await repository.SetProviderAsync(provider, context); + await repository.SetProviderAsync(null, context); + + Assert.Equal(provider, repository.GetProvider()); + provider.Received(1).InitializeAsync(context); + provider.DidNotReceive().ShutdownAsync(); + } + + [Fact] + public async Task Setting_Null_Named_Provider_Removes_It() + { + var repository = new ProviderRepository(); + + var namedProvider = Substitute.For(); + namedProvider.Status.Returns(ProviderStatus.NotReady); + + var defaultProvider = Substitute.For(); + defaultProvider.Status.Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + await repository.SetProviderAsync(defaultProvider, context); + + await repository.SetProviderAsync("named-provider", namedProvider, context); + await repository.SetProviderAsync("named-provider", null, context); + + Assert.Equal(defaultProvider, repository.GetProvider("named-provider")); + } +} diff --git a/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs b/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs new file mode 100644 index 00000000..6a196fd5 --- /dev/null +++ b/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs @@ -0,0 +1,252 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using OpenFeature.Constant; +using OpenFeature.Error; +using OpenFeature.Model; +using OpenFeature.Providers.Memory; +using Xunit; + +namespace OpenFeature.Tests.Providers.Memory; + +public class InMemoryProviderTests +{ + private FeatureProvider commonProvider; + + public InMemoryProviderTests() + { + var provider = new InMemoryProvider(new Dictionary(){ + { + "boolean-flag", new Flag( + variants: new Dictionary(){ + { "on", true }, + { "off", false } + }, + defaultVariant: "on" + ) + }, + { + "string-flag", new Flag( + variants: new Dictionary(){ + { "greeting", "hi" }, + { "parting", "bye" } + }, + defaultVariant: "greeting" + ) + }, + { + "integer-flag", new Flag( + variants: new Dictionary(){ + { "one", 1 }, + { "ten", 10 } + }, + defaultVariant: "ten" + ) + }, + { + "float-flag", new Flag( + variants: new Dictionary(){ + { "tenth", 0.1 }, + { "half", 0.5 } + }, + defaultVariant: "half" + ) + }, + { + "context-aware", new Flag( + variants: new Dictionary(){ + { "internal", "INTERNAL" }, + { "external", "EXTERNAL" } + }, + defaultVariant: "external", + (context) => { + if (context.GetValue("email").AsString?.Contains("@faas.com") == true) + { + return "internal"; + } + else return "external"; + } + ) + }, + { + "object-flag", new Flag( + variants: new Dictionary(){ + { "empty", new Value() }, + { "template", new Value(Structure.Builder() + .Set("showImages", true) + .Set("title", "Check out these pics!") + .Set("imagesPerPage", 100).Build() + ) + } + }, + defaultVariant: "template" + ) + }, + { + "invalid-flag", new Flag( + variants: new Dictionary(){ + { "on", true }, + { "off", false } + }, + defaultVariant: "missing" + ) + }, + { + "invalid-evaluator-flag", new Flag( + variants: new Dictionary(){ + { "on", true }, + { "off", false } + }, + defaultVariant: "on", + (context) => { + return "missing"; + } + ) + } + }); + + this.commonProvider = provider; + } + + [Fact] + public async Task GetBoolean_ShouldEvaluateWithReasonAndVariant() + { + ResolutionDetails details = await this.commonProvider.ResolveBooleanValueAsync("boolean-flag", false, EvaluationContext.Empty); + Assert.True(details.Value); + Assert.Equal(Reason.Static, details.Reason); + Assert.Equal("on", details.Variant); + } + + [Fact] + public async Task GetString_ShouldEvaluateWithReasonAndVariant() + { + ResolutionDetails details = await this.commonProvider.ResolveStringValueAsync("string-flag", "nope", EvaluationContext.Empty); + Assert.Equal("hi", details.Value); + Assert.Equal(Reason.Static, details.Reason); + Assert.Equal("greeting", details.Variant); + } + + [Fact] + public async Task GetInt_ShouldEvaluateWithReasonAndVariant() + { + ResolutionDetails details = await this.commonProvider.ResolveIntegerValueAsync("integer-flag", 13, EvaluationContext.Empty); + Assert.Equal(10, details.Value); + Assert.Equal(Reason.Static, details.Reason); + Assert.Equal("ten", details.Variant); + } + + [Fact] + public async Task GetDouble_ShouldEvaluateWithReasonAndVariant() + { + ResolutionDetails details = await this.commonProvider.ResolveDoubleValueAsync("float-flag", 13, EvaluationContext.Empty); + Assert.Equal(0.5, details.Value); + Assert.Equal(Reason.Static, details.Reason); + Assert.Equal("half", details.Variant); + } + + [Fact] + public async Task GetStruct_ShouldEvaluateWithReasonAndVariant() + { + ResolutionDetails details = await this.commonProvider.ResolveStructureValueAsync("object-flag", new Value(), EvaluationContext.Empty); + Assert.Equal(true, details.Value.AsStructure?["showImages"].AsBoolean); + Assert.Equal("Check out these pics!", details.Value.AsStructure?["title"].AsString); + Assert.Equal(100, details.Value.AsStructure?["imagesPerPage"].AsInteger); + Assert.Equal(Reason.Static, details.Reason); + Assert.Equal("template", details.Variant); + } + + [Fact] + public async Task GetString_ContextSensitive_ShouldEvaluateWithReasonAndVariant() + { + EvaluationContext context = EvaluationContext.Builder().Set("email", "me@faas.com").Build(); + ResolutionDetails details = await this.commonProvider.ResolveStringValueAsync("context-aware", "nope", context); + Assert.Equal("INTERNAL", details.Value); + Assert.Equal(Reason.TargetingMatch, details.Reason); + Assert.Equal("internal", details.Variant); + } + + [Fact] + public async Task EmptyFlags_ShouldWork() + { + var provider = new InMemoryProvider(); + await provider.UpdateFlagsAsync(); + Assert.Equal("InMemory", provider.GetMetadata().Name); + } + + [Fact] + public async Task MissingFlag_ShouldReturnFlagNotFoundEvaluationFlag() + { + // Act + var result = await this.commonProvider.ResolveBooleanValueAsync("missing-flag", false, EvaluationContext.Empty); + + // Assert + Assert.Equal(Reason.Error, result.Reason); + Assert.Equal(ErrorType.FlagNotFound, result.ErrorType); + } + + [Fact] + public async Task MismatchedFlag_ShouldReturnTypeMismatchError() + { + // Act + var result = await this.commonProvider.ResolveStringValueAsync("boolean-flag", "nope", EvaluationContext.Empty); + + // Assert + Assert.Equal(Reason.Error, result.Reason); + Assert.Equal(ErrorType.TypeMismatch, result.ErrorType); + } + + [Fact] + public async Task MissingDefaultVariant_ShouldThrow() + { + await Assert.ThrowsAsync(() => this.commonProvider.ResolveBooleanValueAsync("invalid-flag", false, EvaluationContext.Empty)); + } + + [Fact] + public async Task MissingEvaluatedVariant_ShouldThrow() + { + await Assert.ThrowsAsync(() => this.commonProvider.ResolveBooleanValueAsync("invalid-evaluator-flag", false, EvaluationContext.Empty)); + } + + [Fact] + public async Task PutConfiguration_shouldUpdateConfigAndRunHandlers() + { + var provider = new InMemoryProvider(new Dictionary(){ + { + "old-flag", new Flag( + variants: new Dictionary(){ + { "on", true }, + { "off", false } + }, + defaultVariant: "on" + ) + }}); + + ResolutionDetails details = await provider.ResolveBooleanValueAsync("old-flag", false, EvaluationContext.Empty); + Assert.True(details.Value); + + // update flags + await provider.UpdateFlagsAsync(new Dictionary(){ + { + "new-flag", new Flag( + variants: new Dictionary(){ + { "greeting", "hi" }, + { "parting", "bye" } + }, + defaultVariant: "greeting" + ) + }}); + + var res = await provider.GetEventChannel().Reader.ReadAsync() as ProviderEventPayload; + Assert.Equal(ProviderEventTypes.ProviderConfigurationChanged, res?.Type); + + // old flag should be gone + var oldFlag = await provider.ResolveBooleanValueAsync("old-flag", false, EvaluationContext.Empty); + + Assert.Equal(Reason.Error, oldFlag.Reason); + Assert.Equal(ErrorType.FlagNotFound, oldFlag.ErrorType); + + // new flag should be present, old gone (defaults), handler run. + ResolutionDetails detailsAfter = await provider.ResolveStringValueAsync("new-flag", "nope", EvaluationContext.Empty); + Assert.True(details.Value); + Assert.Equal("hi", detailsAfter.Value); + } +} diff --git a/test/OpenFeature.Tests/StructureTests.cs b/test/OpenFeature.Tests/StructureTests.cs new file mode 100644 index 00000000..484e2b19 --- /dev/null +++ b/test/OpenFeature.Tests/StructureTests.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using OpenFeature.Model; +using Xunit; + +namespace OpenFeature.Tests; + +public class StructureTests +{ + [Fact] + public void No_Arg_Should_Contain_Empty_Attributes() + { + Structure structure = Structure.Empty; + Assert.Equal(0, structure.Count); + Assert.Empty(structure.AsDictionary()); + } + + [Fact] + public void Dictionary_Arg_Should_Contain_New_Dictionary() + { + string KEY = "key"; + IDictionary dictionary = new Dictionary() { { KEY, new Value(KEY) } }; + Structure structure = new Structure(dictionary); + Assert.Equal(KEY, structure.AsDictionary()[KEY].AsString); + Assert.NotSame(structure.AsDictionary(), dictionary); // should be a copy + } + + [Fact] + public void Add_And_Get_Add_And_Return_Values() + { + String BOOL_KEY = "bool"; + String STRING_KEY = "string"; + String INT_KEY = "int"; + String DOUBLE_KEY = "double"; + String DATE_KEY = "date"; + String STRUCT_KEY = "struct"; + String LIST_KEY = "list"; + String VALUE_KEY = "value"; + + bool BOOL_VAL = true; + String STRING_VAL = "val"; + int INT_VAL = 13; + double DOUBLE_VAL = .5; + DateTime DATE_VAL = DateTime.Now; + Structure STRUCT_VAL = Structure.Empty; + IList LIST_VAL = new List(); + Value VALUE_VAL = new Value(); + + var structureBuilder = Structure.Builder(); + structureBuilder.Set(BOOL_KEY, BOOL_VAL); + structureBuilder.Set(STRING_KEY, STRING_VAL); + structureBuilder.Set(INT_KEY, INT_VAL); + structureBuilder.Set(DOUBLE_KEY, DOUBLE_VAL); + structureBuilder.Set(DATE_KEY, DATE_VAL); + structureBuilder.Set(STRUCT_KEY, STRUCT_VAL); + structureBuilder.Set(LIST_KEY, ImmutableList.CreateRange(LIST_VAL)); + structureBuilder.Set(VALUE_KEY, VALUE_VAL); + var structure = structureBuilder.Build(); + + Assert.Equal(BOOL_VAL, structure.GetValue(BOOL_KEY).AsBoolean); + Assert.Equal(STRING_VAL, structure.GetValue(STRING_KEY).AsString); + Assert.Equal(INT_VAL, structure.GetValue(INT_KEY).AsInteger); + Assert.Equal(DOUBLE_VAL, structure.GetValue(DOUBLE_KEY).AsDouble); + Assert.Equal(DATE_VAL, structure.GetValue(DATE_KEY).AsDateTime); + Assert.Equal(STRUCT_VAL, structure.GetValue(STRUCT_KEY).AsStructure); + Assert.Equal(LIST_VAL, structure.GetValue(LIST_KEY).AsList); + Assert.True(structure.GetValue(VALUE_KEY).IsNull); + } + + [Fact] + public void TryGetValue_Should_Return_Value() + { + String KEY = "key"; + String VAL = "val"; + + var structure = Structure.Builder() + .Set(KEY, VAL).Build(); + Value? value; + Assert.True(structure.TryGetValue(KEY, out value)); + Assert.Equal(VAL, value?.AsString); + } + + [Fact] + public void Values_Should_Return_Values() + { + String KEY = "key"; + Value VAL = new Value("val"); + + var structure = Structure.Builder() + .Set(KEY, VAL).Build(); + Assert.Single(structure.Values); + } + + [Fact] + public void Keys_Should_Return_Keys() + { + String KEY = "key"; + Value VAL = new Value("val"); + + var structure = Structure.Builder() + .Set(KEY, VAL).Build(); + Assert.Single(structure.Keys); + Assert.Equal(0, structure.Keys.IndexOf(KEY)); + } + + [Fact] + public void GetEnumerator_Should_Return_Enumerator() + { + string KEY = "key"; + string VAL = "val"; + + var structure = Structure.Builder() + .Set(KEY, VAL).Build(); + IEnumerator> enumerator = structure.GetEnumerator(); + enumerator.MoveNext(); + Assert.Equal(VAL, enumerator.Current.Value.AsString); + } +} diff --git a/test/OpenFeature.Tests/TestImplementations.cs b/test/OpenFeature.Tests/TestImplementations.cs index c731650b..4c298c88 100644 --- a/test/OpenFeature.Tests/TestImplementations.cs +++ b/test/OpenFeature.Tests/TestImplementations.cs @@ -1,77 +1,155 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; using System.Threading.Tasks; -using OpenFeature.SDK.Model; +using OpenFeature.Constant; +using OpenFeature.Model; -namespace OpenFeature.SDK.Tests +namespace OpenFeature.Tests; + +public class TestHookNoOverride : Hook +{ +} + +public class TestHook : Hook { - public class TestStructure + private int _beforeCallCount; + public int BeforeCallCount { get => this._beforeCallCount; } + + private int _afterCallCount; + public int AfterCallCount { get => this._afterCallCount; } + + private int _errorCallCount; + public int ErrorCallCount { get => this._errorCallCount; } + + private int _finallyCallCount; + public int FinallyCallCount { get => this._finallyCallCount; } + + public override ValueTask BeforeAsync(HookContext context, + IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) { - public string Name { get; set; } - public string Value { get; set; } + Interlocked.Increment(ref this._beforeCallCount); + return new ValueTask(EvaluationContext.Empty); } - public class TestHookNoOverride : Hook { } + public override ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, + IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._afterCallCount); + return new ValueTask(); + } - public class TestHook : Hook + public override ValueTask ErrorAsync(HookContext context, Exception error, + IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) { - public override Task Before(HookContext context, IReadOnlyDictionary hints = null) - { - return Task.FromResult(new EvaluationContext()); - } + Interlocked.Increment(ref this._errorCallCount); + return new ValueTask(); + } - public override Task After(HookContext context, FlagEvaluationDetails details, - IReadOnlyDictionary hints = null) - { - return Task.CompletedTask; - } + public override ValueTask FinallyAsync(HookContext context, + FlagEvaluationDetails evaluationDetails, + IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._finallyCallCount); + return new ValueTask(); + } +} - public override Task Error(HookContext context, Exception error, IReadOnlyDictionary hints = null) - { - return Task.CompletedTask; - } +public class TestProvider : FeatureProvider +{ + private readonly List _hooks = new List(); - public override Task Finally(HookContext context, IReadOnlyDictionary hints = null) - { - return Task.CompletedTask; - } + public static string DefaultName = "test-provider"; + private readonly List> TrackingInvocations = []; + + public string? Name { get; set; } + + public void AddHook(Hook hook) => this._hooks.Add(hook); + + public override IImmutableList GetProviderHooks() => this._hooks.ToImmutableList(); + private Exception? initException = null; + private int initDelay = 0; + + public TestProvider() + { + this.Name = DefaultName; } - public class TestProvider : IFeatureProvider + /// + /// A provider used for testing. + /// + /// the name of the provider. + /// Optional exception to throw during init. + /// + public TestProvider(string? name, Exception? initException = null, int initDelay = 0) { - public static string Name => "test-provider"; + this.Name = string.IsNullOrEmpty(name) ? DefaultName : name; + this.initException = initException; + this.initDelay = initDelay; + } - public Metadata GetMetadata() - { - return new Metadata(Name); - } + public ImmutableList> GetTrackingInvocations() + { + return this.TrackingInvocations.ToImmutableList(); + } - public Task> ResolveBooleanValue(string flagKey, bool defaultValue, - EvaluationContext context = null, - FlagEvaluationOptions config = null) - { - throw new NotImplementedException(); - } + public void Reset() + { + this.TrackingInvocations.Clear(); + } - public Task> ResolveStringValue(string flagKey, string defaultValue, - EvaluationContext context = null, - FlagEvaluationOptions config = null) - { - throw new NotImplementedException(); - } + public override Metadata GetMetadata() + { + return new Metadata(this.Name); + } - public Task> ResolveNumberValue(string flagKey, int defaultValue, - EvaluationContext context = null, - FlagEvaluationOptions config = null) - { - throw new NotImplementedException(); - } + public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(new ResolutionDetails(flagKey, !defaultValue)); + } + + public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } - public Task> ResolveStructureValue(string flagKey, T defaultValue, - EvaluationContext context = null, - FlagEvaluationOptions config = null) + public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } + + public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } + + public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } + + public override async Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) + { + await Task.Delay(this.initDelay).ConfigureAwait(false); + if (this.initException != null) { - throw new NotImplementedException(); + throw this.initException; } } + + public override void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default) + { + this.TrackingInvocations.Add(new Tuple(trackingEventName, evaluationContext, trackingEventDetails)); + } + + internal ValueTask SendEventAsync(ProviderEventTypes eventType, CancellationToken cancellationToken = default) + { + return this.EventChannel.Writer.WriteAsync(new ProviderEventPayload { Type = eventType, ProviderName = this.GetMetadata().Name, }, cancellationToken); + } } diff --git a/test/OpenFeature.Tests/TestUtils.cs b/test/OpenFeature.Tests/TestUtils.cs new file mode 100644 index 00000000..15348db2 --- /dev/null +++ b/test/OpenFeature.Tests/TestUtils.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +internal class Utils +{ + /// + /// Repeatedly runs the supplied assertion until it doesn't throw, or the timeout is reached. + /// + /// Function which makes an assertion + /// Timeout in millis (defaults to 1000) + /// Poll interval (defaults to 100 + /// + public static async Task AssertUntilAsync(Action assertionFunc, int timeoutMillis = 1000, int pollIntervalMillis = 100) + { + using (var cts = CancellationTokenSource.CreateLinkedTokenSource(default(CancellationToken))) + { + + cts.CancelAfter(timeoutMillis); + + var exceptions = new List(); + var message = "AssertUntilAsync timeout reached."; + + while (!cts.IsCancellationRequested) + { + try + { + assertionFunc(cts.Token); + return; + } + catch (TaskCanceledException) when (cts.IsCancellationRequested) + { + throw new AggregateException(message, exceptions); + } + catch (Exception e) + { + exceptions.Add(e); + } + + try + { + await Task.Delay(pollIntervalMillis, cts.Token).ConfigureAwait(false); + } + catch (TaskCanceledException) + { + throw new AggregateException(message, exceptions); + } + } + throw new AggregateException(message, exceptions); + } + } +} diff --git a/test/OpenFeature.Tests/TestUtilsTest.cs b/test/OpenFeature.Tests/TestUtilsTest.cs new file mode 100644 index 00000000..ab7867ba --- /dev/null +++ b/test/OpenFeature.Tests/TestUtilsTest.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading.Tasks; +using Xunit; + +namespace OpenFeature.Tests; + +public class TestUtilsTest +{ + [Fact] + public async Task Should_Fail_If_Assertion_Fails() + { + await Assert.ThrowsAnyAsync(() => Utils.AssertUntilAsync(_ => Assert.True(1.Equals(2)), 100, 10)); + } + + [Fact] + public async Task Should_Pass_If_Assertion_Fails() + { + await Utils.AssertUntilAsync(_ => Assert.True(1.Equals(1))); + } +} diff --git a/test/OpenFeature.Tests/TrackingEventDetailsTest.cs b/test/OpenFeature.Tests/TrackingEventDetailsTest.cs new file mode 100644 index 00000000..22b1ce45 --- /dev/null +++ b/test/OpenFeature.Tests/TrackingEventDetailsTest.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using OpenFeature.Model; +using OpenFeature.Tests.Internal; +using Xunit; + +namespace OpenFeature.Tests; + +public class TrackingEventDetailsTest +{ + [Fact] + [Specification("6.2.1", "The `tracking event details` structure MUST define an optional numeric `value`, associating a scalar quality with an `tracking event`.")] + public void TrackingEventDetails_HasAnOptionalValueProperty() + { + var builder = new TrackingEventDetailsBuilder(); + var details = builder.Build(); + Assert.Null(details.Value); + } + + [Fact] + [Specification("6.2.1", "The `tracking event details` structure MUST define an optional numeric `value`, associating a scalar quality with an `tracking event`.")] + public void TrackingEventDetails_HasAValueProperty() + { + const double value = 23.5; + var builder = new TrackingEventDetailsBuilder().SetValue(value); + var details = builder.Build(); + Assert.Equal(value, details.Value); + } + + [Fact] + [Specification("6.2.2", "The `tracking event details` MUST support the inclusion of custom fields, having keys of type `string`, and values of type `boolean | string | number | structure`.")] + public void TrackingEventDetails_CanTakeValues() + { + var structure = new Structure(new Dictionary { { "key", new Value("value") } }); + var dateTimeValue = new Value(DateTime.Now); + var builder = TrackingEventDetails.Builder() + .Set("boolean", true) + .Set("string", "some string") + .Set("double", 123.3) + .Set("structure", structure) + .Set("value", dateTimeValue); + var details = builder.Build(); + Assert.Equal(5, details.Count); + Assert.Equal(true, details.GetValue("boolean").AsBoolean); + Assert.Equal("some string", details.GetValue("string").AsString); + Assert.Equal(123.3, details.GetValue("double").AsDouble); + Assert.Equal(structure, details.GetValue("structure").AsStructure); + Assert.Equal(dateTimeValue, details.GetValue("value")); + } +} diff --git a/test/OpenFeature.Tests/ValueTests.cs b/test/OpenFeature.Tests/ValueTests.cs new file mode 100644 index 00000000..34a2eb6b --- /dev/null +++ b/test/OpenFeature.Tests/ValueTests.cs @@ -0,0 +1,236 @@ +using System; +using System.Collections.Generic; +using OpenFeature.Model; +using Xunit; + +namespace OpenFeature.Tests; + +public class ValueTests +{ + class Foo + { + } + + [Fact] + public void No_Arg_Should_Contain_Null() + { + Value value = new Value(); + Assert.True(value.IsNull); + } + + [Fact] + public void Object_Arg_Should_Contain_Object() + { + // int is a special case, see Int_Object_Arg_Should_Contain_Object() + IList list = new List() + { + true, + "val", + .5, + Structure.Empty, + new List(), + DateTime.Now + }; + + int i = 0; + foreach (Object l in list) + { + Value value = new Value(l); + Assert.Equal(list[i], value.AsObject); + i++; + } + } + + [Fact] + public void Int_Object_Arg_Should_Contain_Object() + { + try + { + int innerValue = 1; + Value value = new Value(innerValue); + Assert.True(value.IsNumber); + Assert.Equal(innerValue, value.AsInteger); + } + catch (Exception) + { + Assert.Fail("Expected no exception."); + } + } + + [Fact] + public void Invalid_Object_Should_Throw() + { + Assert.Throws(() => + { + return new Value(new Foo()); + }); + } + + [Fact] + public void Bool_Arg_Should_Contain_Bool() + { + bool innerValue = true; + Value value = new Value(innerValue); + Assert.True(value.IsBoolean); + Assert.Equal(innerValue, value.AsBoolean); + } + + [Fact] + public void Numeric_Arg_Should_Return_Double_Or_Int() + { + double innerDoubleValue = .75; + Value doubleValue = new Value(innerDoubleValue); + Assert.True(doubleValue.IsNumber); + Assert.Equal(1, doubleValue.AsInteger); // should be rounded + Assert.Equal(.75, doubleValue.AsDouble); + + int innerIntValue = 100; + Value intValue = new Value(innerIntValue); + Assert.True(intValue.IsNumber); + Assert.Equal(innerIntValue, intValue.AsInteger); + Assert.Equal(innerIntValue, intValue.AsDouble); + } + + [Fact] + public void String_Arg_Should_Contain_String() + { + string innerValue = "hi!"; + Value value = new Value(innerValue); + Assert.True(value.IsString); + Assert.Equal(innerValue, value.AsString); + } + + [Fact] + public void DateTime_Arg_Should_Contain_DateTime() + { + DateTime innerValue = new DateTime(); + Value value = new Value(innerValue); + Assert.True(value.IsDateTime); + Assert.Equal(innerValue, value.AsDateTime); + } + + [Fact] + public void Structure_Arg_Should_Contain_Structure() + { + string INNER_KEY = "key"; + string INNER_VALUE = "val"; + Structure innerValue = Structure.Builder().Set(INNER_KEY, INNER_VALUE).Build(); + Value value = new Value(innerValue); + Assert.True(value.IsStructure); + Assert.Equal(INNER_VALUE, value.AsStructure?.GetValue(INNER_KEY).AsString); + } + + [Fact] + public void List_Arg_Should_Contain_List() + { + string ITEM_VALUE = "val"; + IList innerValue = new List() { new Value(ITEM_VALUE) }; + Value value = new Value(innerValue); + Assert.True(value.IsList); + Assert.Equal(ITEM_VALUE, value.AsList?[0].AsString); + } + + [Fact] + public void Constructor_WhenCalledWithAnotherValue_CopiesInnerValue() + { + // Arrange + var originalValue = new Value("testValue"); + + // Act + var copiedValue = new Value(originalValue); + + // Assert + Assert.Equal(originalValue.AsObject, copiedValue.AsObject); + } + + [Fact] + public void AsInteger_WhenCalledWithNonIntegerInnerValue_ReturnsNull() + { + // Arrange + var value = new Value("test"); + + // Act + var actualValue = value.AsInteger; + + // Assert + Assert.Null(actualValue); + } + + [Fact] + public void AsBoolean_WhenCalledWithNonBooleanInnerValue_ReturnsNull() + { + // Arrange + var value = new Value("test"); + + // Act + var actualValue = value.AsBoolean; + + // Assert + Assert.Null(actualValue); + } + + [Fact] + public void AsDouble_WhenCalledWithNonDoubleInnerValue_ReturnsNull() + { + // Arrange + var value = new Value("test"); + + // Act + var actualValue = value.AsDouble; + + // Assert + Assert.Null(actualValue); + } + + [Fact] + public void AsString_WhenCalledWithNonStringInnerValue_ReturnsNull() + { + // Arrange + var value = new Value(123); + + // Act + var actualValue = value.AsString; + + // Assert + Assert.Null(actualValue); + } + + [Fact] + public void AsStructure_WhenCalledWithNonStructureInnerValue_ReturnsNull() + { + // Arrange + var value = new Value("test"); + + // Act + var actualValue = value.AsStructure; + + // Assert + Assert.Null(actualValue); + } + + [Fact] + public void AsList_WhenCalledWithNonListInnerValue_ReturnsNull() + { + // Arrange + var value = new Value("test"); + + // Act + var actualValue = value.AsList; + + // Assert + Assert.Null(actualValue); + } + + [Fact] + public void AsDateTime_WhenCalledWithNonDateTimeInnerValue_ReturnsNull() + { + // Arrange + var value = new Value("test"); + + // Act + var actualValue = value.AsDateTime; + + // Assert + Assert.Null(actualValue); + } +} diff --git a/version.txt b/version.txt new file mode 100644 index 00000000..437459cd --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +2.5.0