diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml index afc537e4..8f885bd8 100644 --- a/.github/workflows/lint-pr.yml +++ b/.github/workflows/lint-pr.yml @@ -18,6 +18,6 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@ff373f4e8056b732dfd0eadd42ae54c004e5523b + - uses: amannn/action-semantic-pull-request@00282d63cda40a6eaf3e9d0cbb1ac4384de896e8 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/merge.yml b/.github/workflows/merge.yml index 6d4127fc..6a8a4be7 100644 --- a/.github/workflows/merge.yml +++ b/.github/workflows/merge.yml @@ -20,9 +20,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 + - uses: actions/checkout@96f53100ba2a5449eb71d2e6604bbcd94b9449b5 - name: Set up JDK 8 - uses: actions/setup-java@5ffc13f4174014e2d4d4572b3d74c3fa61aeb2c2 + uses: actions/setup-java@75c6561172d237e514a15dfd831cb331e5506d5e with: java-version: '8' distribution: 'temurin' @@ -32,7 +32,7 @@ jobs: server-password: ${{ secrets.OSSRH_PASSWORD }} - name: Cache local Maven repository - uses: actions/cache@04f198bf0b2a39f7230a4304bf07747a0bddf146 + uses: actions/cache@67b839edb68371cc5014f6cea11c9aa77238de78 with: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} @@ -49,8 +49,9 @@ jobs: run: mvn --batch-mode --update-snapshots verify - name: Upload coverage to Codecov - uses: codecov/codecov-action@cc7fb3f71c712c470a895ac29f8a1fd0fcb52c8a + uses: codecov/codecov-action@eaaf4bedf32dbdc6b720b63067d99c4d77d6047d with: + token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos flags: unittests # optional name: coverage # optional fail_ci_if_error: true # optional (default = false) diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index e41418bd..f3db5d6a 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -16,33 +16,33 @@ jobs: steps: - name: Check out the code - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 + uses: actions/checkout@96f53100ba2a5449eb71d2e6604bbcd94b9449b5 - name: Set up JDK 8 - uses: actions/setup-java@5ffc13f4174014e2d4d4572b3d74c3fa61aeb2c2 + uses: actions/setup-java@75c6561172d237e514a15dfd831cb331e5506d5e with: java-version: '8' distribution: 'temurin' cache: maven - name: Initialize CodeQL - uses: github/codeql-action/init@f9c159f4fded57bd3c8000e5de554cb90fa0b265 + uses: github/codeql-action/init@c5526174a564f5a5444d71884af87163f19cf394 with: languages: java - name: Cache local Maven repository - uses: actions/cache@04f198bf0b2a39f7230a4304bf07747a0bddf146 + uses: actions/cache@67b839edb68371cc5014f6cea11c9aa77238de78 with: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} restore-keys: | ${{ runner.os }}-maven- - - name: Build with Maven - run: mvn --batch-mode --update-snapshots verify -P integration-test + - name: Verify with Maven + run: mvn --batch-mode --update-snapshots verify -P e2e-test - name: Upload coverage to Codecov - uses: codecov/codecov-action@cc7fb3f71c712c470a895ac29f8a1fd0fcb52c8a + uses: codecov/codecov-action@eaaf4bedf32dbdc6b720b63067d99c4d77d6047d with: token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos flags: unittests # optional @@ -51,4 +51,4 @@ jobs: verbose: true # optional (default = false) - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f9c159f4fded57bd3c8000e5de554cb90fa0b265 + uses: github/codeql-action/analyze@c5526174a564f5a5444d71884af87163f19cf394 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 346b75b6..611e7952 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: # Release-please creates a PR that tracks all changes steps: - - uses: google-github-actions/release-please-action@9997fc940dddf620986d5e88532ffb2cc6e22c1c + - uses: google-github-actions/release-please-action@01f98cb9de537919302b1694069728b853c652ea id: release with: command: manifest @@ -29,10 +29,10 @@ jobs: # These steps are only run if this was a merged release-please PR - name: checkout if: ${{ steps.release.outputs.releases_created }} - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 + uses: actions/checkout@96f53100ba2a5449eb71d2e6604bbcd94b9449b5 - name: Set up JDK 8 if: ${{ steps.release.outputs.releases_created }} - uses: actions/setup-java@5ffc13f4174014e2d4d4572b3d74c3fa61aeb2c2 + uses: actions/setup-java@75c6561172d237e514a15dfd831cb331e5506d5e with: java-version: '8' distribution: 'temurin' diff --git a/.github/workflows/static-code-scanning.yaml b/.github/workflows/static-code-scanning.yaml index 4457de72..ffcf6db1 100644 --- a/.github/workflows/static-code-scanning.yaml +++ b/.github/workflows/static-code-scanning.yaml @@ -29,16 +29,16 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 + uses: actions/checkout@96f53100ba2a5449eb71d2e6604bbcd94b9449b5 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@f9c159f4fded57bd3c8000e5de554cb90fa0b265 + uses: github/codeql-action/init@c5526174a564f5a5444d71884af87163f19cf394 with: languages: java - name: Autobuild - uses: github/codeql-action/autobuild@f9c159f4fded57bd3c8000e5de554cb90fa0b265 + uses: github/codeql-action/autobuild@c5526174a564f5a5444d71884af87163f19cf394 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f9c159f4fded57bd3c8000e5de554cb90fa0b265 + uses: github/codeql-action/analyze@c5526174a564f5a5444d71884af87163f19cf394 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 00d9fffc..802de1e8 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1 +1 @@ -{".":"1.3.1"} \ No newline at end of file +{".":"1.4.0"} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e59bc30f..3b72181f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,150 @@ # Changelog +## [1.4.0](https://github.com/open-feature/java-sdk/compare/v1.3.1...v1.4.0) (2023-07-13) + + +### ๐Ÿ› Bug Fixes + +* **deps:** update dependency io.cucumber:cucumber-bom to v7.12.0 ([#413](https://github.com/open-feature/java-sdk/issues/413)) ([f0f5d28](https://github.com/open-feature/java-sdk/commit/f0f5d284169081cae8fc88cffa04d17ac776a51f)) +* **deps:** update dependency io.cucumber:cucumber-bom to v7.12.1 ([#461](https://github.com/open-feature/java-sdk/issues/461)) ([c26b755](https://github.com/open-feature/java-sdk/commit/c26b75593edaf3a6f87c85bb9065ec485612c723)) +* **deps:** update dependency io.cucumber:cucumber-bom to v7.13.0 ([#499](https://github.com/open-feature/java-sdk/issues/499)) ([da00304](https://github.com/open-feature/java-sdk/commit/da0030408307b580fd912f5bf390b599f7a79024)) +* **deps:** update dependency org.projectlombok:lombok to v1.18.28 ([#448](https://github.com/open-feature/java-sdk/issues/448)) ([dfb214c](https://github.com/open-feature/java-sdk/commit/dfb214c52f89a84becd170925721179a2ff24c75)) +* **deps:** update junit5 monorepo ([#410](https://github.com/open-feature/java-sdk/issues/410)) ([854d0be](https://github.com/open-feature/java-sdk/commit/854d0be0f473c212884163680e2ee5df5eded0c6)) + + +### โœจ New Features + +* add empty constructors to data classes ([#491](https://github.com/open-feature/java-sdk/issues/491)) ([693721e](https://github.com/open-feature/java-sdk/commit/693721e36c5b31adacd96afc55bc38ed53534db4)) +* add flag metadata ([#459](https://github.com/open-feature/java-sdk/issues/459)) ([3ed40a3](https://github.com/open-feature/java-sdk/commit/3ed40a388797dc6939bff5d06e7c4528773df791)) +* add initialize and shutdown behavior ([#456](https://github.com/open-feature/java-sdk/issues/456)) ([5f173ff](https://github.com/open-feature/java-sdk/commit/5f173ff8607e8430bf14a57e7782dc0e8460317a)) +* events ([#476](https://github.com/open-feature/java-sdk/issues/476)) ([bad5b0a](https://github.com/open-feature/java-sdk/commit/bad5b0a7f5167d0b57bf502ce86b32b1c538746c)) +* Support mapping a client to a given provider. ([#388](https://github.com/open-feature/java-sdk/issues/388)) ([d4c43d7](https://github.com/open-feature/java-sdk/commit/d4c43d74bc37371fc19dc1983e96e7c904d5a1e7)) + + +### ๐Ÿงน Chore + +* **deps:** update actions/cache digest to 67b839e ([#473](https://github.com/open-feature/java-sdk/issues/473)) ([6d456ca](https://github.com/open-feature/java-sdk/commit/6d456ca618ba78eadcfe00bd63383b9f7dba32b0)) +* **deps:** update actions/checkout digest to 47fbe2d ([#393](https://github.com/open-feature/java-sdk/issues/393)) ([43a75d0](https://github.com/open-feature/java-sdk/commit/43a75d080c3594669fe6c594b2818ee9fe22955e)) +* **deps:** update actions/checkout digest to 83b7061 ([#389](https://github.com/open-feature/java-sdk/issues/389)) ([f3e65db](https://github.com/open-feature/java-sdk/commit/f3e65db54e24926f529e939b5a27f605c46f3185)) +* **deps:** update actions/checkout digest to 8e5e7e5 ([#391](https://github.com/open-feature/java-sdk/issues/391)) ([9c98e83](https://github.com/open-feature/java-sdk/commit/9c98e83ed6a1a471b6c5488b8c87681fd92dd77d)) +* **deps:** update actions/checkout digest to 96f5310 ([#471](https://github.com/open-feature/java-sdk/issues/471)) ([fe42073](https://github.com/open-feature/java-sdk/commit/fe420733850018b1d579601a1d3b4149a93605d6)) +* **deps:** update actions/checkout digest to f095bcc ([#398](https://github.com/open-feature/java-sdk/issues/398)) ([3015571](https://github.com/open-feature/java-sdk/commit/30155712bc35a070febc5761b1ad03dc25183a26)) +* **deps:** update actions/setup-java digest to 191ba8c ([#375](https://github.com/open-feature/java-sdk/issues/375)) ([bdb08d7](https://github.com/open-feature/java-sdk/commit/bdb08d7af809bfb4593cf38830b843fb433a95ae)) +* **deps:** update actions/setup-java digest to 1f2faad ([#484](https://github.com/open-feature/java-sdk/issues/484)) ([c3528da](https://github.com/open-feature/java-sdk/commit/c3528da7024fa585ce265a620bca1f936ec508c1)) +* **deps:** update actions/setup-java digest to 45058d7 ([#479](https://github.com/open-feature/java-sdk/issues/479)) ([ec6d44a](https://github.com/open-feature/java-sdk/commit/ec6d44ae8969f73098fac8e98830800486b73a9a)) +* **deps:** update actions/setup-java digest to 75c6561 ([#503](https://github.com/open-feature/java-sdk/issues/503)) ([2d3b644](https://github.com/open-feature/java-sdk/commit/2d3b6448963e794242babe016597fb5aa198afaf)) +* **deps:** update actions/setup-java digest to 87c1c70 ([#469](https://github.com/open-feature/java-sdk/issues/469)) ([89cedb9](https://github.com/open-feature/java-sdk/commit/89cedb9d2ec709d7ad218e8b94852f8b947eb7f6)) +* **deps:** update actions/setup-java digest to ddb82ce ([#381](https://github.com/open-feature/java-sdk/issues/381)) ([a737c3a](https://github.com/open-feature/java-sdk/commit/a737c3a36bb5899c7e4b1efab69a3d4f13f24325)) +* **deps:** update actions/setup-java digest to e42168c ([#371](https://github.com/open-feature/java-sdk/issues/371)) ([0ce5b43](https://github.com/open-feature/java-sdk/commit/0ce5b43a81d5334460e8724f55798486bc9813d0)) +* **deps:** update amannn/action-semantic-pull-request digest to 00282d6 ([#490](https://github.com/open-feature/java-sdk/issues/490)) ([8b9e050](https://github.com/open-feature/java-sdk/commit/8b9e0500924475bccd3f069f5967b5af59d50f12)) +* **deps:** update amannn/action-semantic-pull-request digest to 3bb5af3 ([#435](https://github.com/open-feature/java-sdk/issues/435)) ([88e7d60](https://github.com/open-feature/java-sdk/commit/88e7d6054f60c15dc6f1130c4b6fe77be7098a5d)) +* **deps:** update codecov/codecov-action digest to 1dd0ce3 ([#414](https://github.com/open-feature/java-sdk/issues/414)) ([9d7d3d4](https://github.com/open-feature/java-sdk/commit/9d7d3d41f6a8e75b9a2b02e755a4c4048a3bc611)) +* **deps:** update codecov/codecov-action digest to 40a12dc ([#385](https://github.com/open-feature/java-sdk/issues/385)) ([5072553](https://github.com/open-feature/java-sdk/commit/507255316614cef8653c833d7c83322f999485ef)) +* **deps:** update codecov/codecov-action digest to 49c20db ([#431](https://github.com/open-feature/java-sdk/issues/431)) ([106df46](https://github.com/open-feature/java-sdk/commit/106df4661dd8e46da698ea4c90e8447aa958e6b7)) +* **deps:** update codecov/codecov-action digest to 5bf2504 ([#418](https://github.com/open-feature/java-sdk/issues/418)) ([19415ed](https://github.com/open-feature/java-sdk/commit/19415edb713533d606b6483fb8fdfea4b838b133)) +* **deps:** update codecov/codecov-action digest to 6757614 ([#400](https://github.com/open-feature/java-sdk/issues/400)) ([427d5a6](https://github.com/open-feature/java-sdk/commit/427d5a627251061651040e841d5e4db582f03cd4)) +* **deps:** update codecov/codecov-action digest to 894ff02 ([#402](https://github.com/open-feature/java-sdk/issues/402)) ([212590e](https://github.com/open-feature/java-sdk/commit/212590e5e26317a4e70caca4d201e21cb7ffa7b4)) +* **deps:** update codecov/codecov-action digest to 91e1847 ([#372](https://github.com/open-feature/java-sdk/issues/372)) ([dfa08b9](https://github.com/open-feature/java-sdk/commit/dfa08b90e1cf3f698f9058f95e7e41567a1934bb)) +* **deps:** update codecov/codecov-action digest to b4dfea7 ([#419](https://github.com/open-feature/java-sdk/issues/419)) ([b7dd2fc](https://github.com/open-feature/java-sdk/commit/b7dd2fc5a2ad9d00fb6500b1647ffb70dd539b45)) +* **deps:** update codecov/codecov-action digest to cf8e3e4 ([#428](https://github.com/open-feature/java-sdk/issues/428)) ([59d8a10](https://github.com/open-feature/java-sdk/commit/59d8a10ba311f7808a02db2773bad64356dda8e3)) +* **deps:** update codecov/codecov-action digest to eaaf4be ([#433](https://github.com/open-feature/java-sdk/issues/433)) ([3ff9995](https://github.com/open-feature/java-sdk/commit/3ff9995a437508ebb227c24e1c731dee447fc092)) +* **deps:** update dependency com.github.spotbugs:spotbugs-maven-plugin to v4.7.3.4 ([#380](https://github.com/open-feature/java-sdk/issues/380)) ([ec3111f](https://github.com/open-feature/java-sdk/commit/ec3111f5d7fb12343a45ff70296238cf747554ef)) +* **deps:** update dependency com.github.spotbugs:spotbugs-maven-plugin to v4.7.3.5 ([#482](https://github.com/open-feature/java-sdk/issues/482)) ([b8b927e](https://github.com/open-feature/java-sdk/commit/b8b927ef4a418c32effb9fbc644667ec48a4ce7e)) +* **deps:** update dependency com.google.guava:guava to v32 ([#455](https://github.com/open-feature/java-sdk/issues/455)) ([5888aea](https://github.com/open-feature/java-sdk/commit/5888aead97a70495b8fd9489aa1a8b23ea2f365e)) +* **deps:** update dependency com.google.guava:guava to v32.0.1-jre ([#470](https://github.com/open-feature/java-sdk/issues/470)) ([3946211](https://github.com/open-feature/java-sdk/commit/3946211c5d042f17a04d6941430462f70b27a7d2)) +* **deps:** update dependency com.google.guava:guava to v32.1.0-jre ([#492](https://github.com/open-feature/java-sdk/issues/492)) ([207a221](https://github.com/open-feature/java-sdk/commit/207a221d4674c8cda7881ee41c1515048a0a059e)) +* **deps:** update dependency com.google.guava:guava to v32.1.1-jre ([#494](https://github.com/open-feature/java-sdk/issues/494)) ([a7c7d42](https://github.com/open-feature/java-sdk/commit/a7c7d4287960d6825a57d14d9878032d2d2170d0)) +* **deps:** update dependency dev.openfeature.contrib.providers:flagd to v0.5.10 ([#429](https://github.com/open-feature/java-sdk/issues/429)) ([5388fa1](https://github.com/open-feature/java-sdk/commit/5388fa12b61d127588aca02999d26bc3c9986b1c)) +* **deps:** update dependency dev.openfeature.contrib.providers:flagd to v0.5.8 ([#360](https://github.com/open-feature/java-sdk/issues/360)) ([de9a928](https://github.com/open-feature/java-sdk/commit/de9a928f93679295ad9244b7dc6def1af1d9f7fc)) +* **deps:** update dependency dev.openfeature.contrib.providers:flagd to v0.5.9 ([#416](https://github.com/open-feature/java-sdk/issues/416)) ([434da5a](https://github.com/open-feature/java-sdk/commit/434da5a6080a8c3827a9a9cbb08ed98107c14264)) +* **deps:** update dependency org.apache.maven.plugins:maven-checkstyle-plugin to v3.2.2 ([#403](https://github.com/open-feature/java-sdk/issues/403)) ([311b73f](https://github.com/open-feature/java-sdk/commit/311b73fe353ea723f2aa8df70b7ec92e91b8d0f8)) +* **deps:** update dependency org.apache.maven.plugins:maven-checkstyle-plugin to v3.3.0 ([#444](https://github.com/open-feature/java-sdk/issues/444)) ([f9523ec](https://github.com/open-feature/java-sdk/commit/f9523ecd8b4585619ea6e12caffcb90c42eb354c)) +* **deps:** update dependency org.apache.maven.plugins:maven-dependency-plugin to v3.6.0 ([#445](https://github.com/open-feature/java-sdk/issues/445)) ([eb6f9e6](https://github.com/open-feature/java-sdk/commit/eb6f9e69ef8729d2850f8c1e63a66f30c0a8dd51)) +* **deps:** update dependency org.apache.maven.plugins:maven-failsafe-plugin to v3.1.0 ([#425](https://github.com/open-feature/java-sdk/issues/425)) ([839fddb](https://github.com/open-feature/java-sdk/commit/839fddb927575d92ed114518d9f2c16a92a0994b)) +* **deps:** update dependency org.apache.maven.plugins:maven-failsafe-plugin to v3.1.2 ([#464](https://github.com/open-feature/java-sdk/issues/464)) ([24f0923](https://github.com/open-feature/java-sdk/commit/24f092319dfade89b2a6a62b86cce2d88b81fa3a)) +* **deps:** update dependency org.apache.maven.plugins:maven-gpg-plugin to v3.1.0 ([#423](https://github.com/open-feature/java-sdk/issues/423)) ([64f79cd](https://github.com/open-feature/java-sdk/commit/64f79cd513c698656eed2b10903b60ef3891c141)) +* **deps:** update dependency org.apache.maven.plugins:maven-pmd-plugin to v3.21.0 ([#434](https://github.com/open-feature/java-sdk/issues/434)) ([4d65590](https://github.com/open-feature/java-sdk/commit/4d655900d94351de8700120acb90d4429e15a136)) +* **deps:** update dependency org.apache.maven.plugins:maven-source-plugin to v3.3.0 ([#443](https://github.com/open-feature/java-sdk/issues/443)) ([bcbaff8](https://github.com/open-feature/java-sdk/commit/bcbaff8e4f15122d7e083ce68af7c0446adaf9fa)) +* **deps:** update dependency org.apache.maven.plugins:maven-surefire-plugin to v3.1.0 ([#426](https://github.com/open-feature/java-sdk/issues/426)) ([0ccf337](https://github.com/open-feature/java-sdk/commit/0ccf337384a3ffd286560ad29a3f4531998e8e2b)) +* **deps:** update dependency org.apache.maven.plugins:maven-surefire-plugin to v3.1.2 ([#465](https://github.com/open-feature/java-sdk/issues/465)) ([6107e91](https://github.com/open-feature/java-sdk/commit/6107e91be4eef92e5dfa96e6b7b862d7e3a85df1)) +* **deps:** update dependency org.codehaus.mojo:build-helper-maven-plugin to v3.4.0 ([#432](https://github.com/open-feature/java-sdk/issues/432)) ([aa495b2](https://github.com/open-feature/java-sdk/commit/aa495b28470d9a75bd64c148260b352dd8e6c6c2)) +* **deps:** update dependency org.cyclonedx:cyclonedx-maven-plugin to v2.7.6 ([#370](https://github.com/open-feature/java-sdk/issues/370)) ([d7b3ca0](https://github.com/open-feature/java-sdk/commit/d7b3ca0513f80e933d25d6ada2ef3cbbbf961b38)) +* **deps:** update dependency org.cyclonedx:cyclonedx-maven-plugin to v2.7.7 ([#396](https://github.com/open-feature/java-sdk/issues/396)) ([a5eaf79](https://github.com/open-feature/java-sdk/commit/a5eaf79cf9d039ab5319d8c4101b0fd8c395166e)) +* **deps:** update dependency org.cyclonedx:cyclonedx-maven-plugin to v2.7.8 ([#408](https://github.com/open-feature/java-sdk/issues/408)) ([c426e66](https://github.com/open-feature/java-sdk/commit/c426e6646f55e6575f0e1c044e4aa2a8efc2e0c0)) +* **deps:** update dependency org.cyclonedx:cyclonedx-maven-plugin to v2.7.9 ([#438](https://github.com/open-feature/java-sdk/issues/438)) ([c3e82e9](https://github.com/open-feature/java-sdk/commit/c3e82e97ddf071916b9c9e287bc935f5d177d01d)) +* **deps:** update dependency org.jacoco:jacoco-maven-plugin to v0.8.10 ([#407](https://github.com/open-feature/java-sdk/issues/407)) ([5b10d39](https://github.com/open-feature/java-sdk/commit/5b10d399cb2fdf38fe46105d53fa2ae36eb2e0b2)) +* **deps:** update dependency org.jacoco:jacoco-maven-plugin to v0.8.9 ([#374](https://github.com/open-feature/java-sdk/issues/374)) ([ade4878](https://github.com/open-feature/java-sdk/commit/ade4878abc1efd993f2dc2977adbc0cc45b84be2)) +* **deps:** update github/codeql-action digest to 0ac1815 ([#477](https://github.com/open-feature/java-sdk/issues/477)) ([3501425](https://github.com/open-feature/java-sdk/commit/3501425f48feef82a50161ed072a68bae97053c9)) +* **deps:** update github/codeql-action digest to 11ea309 ([#447](https://github.com/open-feature/java-sdk/issues/447)) ([8d675ca](https://github.com/open-feature/java-sdk/commit/8d675ca38751e9c7bb8c7dd74591b9992cb696ec)) +* **deps:** update github/codeql-action digest to 1245696 ([#446](https://github.com/open-feature/java-sdk/issues/446)) ([e393b64](https://github.com/open-feature/java-sdk/commit/e393b64715f27cd96a1823c537c6d4c58030e0a2)) +* **deps:** update github/codeql-action digest to 12aa0a6 ([#505](https://github.com/open-feature/java-sdk/issues/505)) ([893d0da](https://github.com/open-feature/java-sdk/commit/893d0da6126ce49c73a90d20094a2e0123300ebb)) +* **deps:** update github/codeql-action digest to 130884e ([#430](https://github.com/open-feature/java-sdk/issues/430)) ([6405100](https://github.com/open-feature/java-sdk/commit/6405100b275f6465bbdcd25b5158ff6aad386f8b)) +* **deps:** update github/codeql-action digest to 1e1aca8 ([#421](https://github.com/open-feature/java-sdk/issues/421)) ([7aade9a](https://github.com/open-feature/java-sdk/commit/7aade9a875245ededdb56597a89f4745f0d58622)) +* **deps:** update github/codeql-action digest to 2d031a3 ([#451](https://github.com/open-feature/java-sdk/issues/451)) ([fa1e144](https://github.com/open-feature/java-sdk/commit/fa1e14451d04663dabc314e25e6ddf5ba1fb2ecf)) +* **deps:** update github/codeql-action digest to 318bcc7 ([#420](https://github.com/open-feature/java-sdk/issues/420)) ([42b9317](https://github.com/open-feature/java-sdk/commit/42b931776a559fcc35ddee442d60bce6e86b16dd)) +* **deps:** update github/codeql-action digest to 46a6823 ([#493](https://github.com/open-feature/java-sdk/issues/493)) ([331d511](https://github.com/open-feature/java-sdk/commit/331d5110dab6e4806a5a45301d2f16c86d764644)) +* **deps:** update github/codeql-action digest to 5f061ca ([#450](https://github.com/open-feature/java-sdk/issues/450)) ([79222e1](https://github.com/open-feature/java-sdk/commit/79222e1cf7223ceee75f587804904492ca004b74)) +* **deps:** update github/codeql-action digest to 66aeadb ([#377](https://github.com/open-feature/java-sdk/issues/377)) ([5c335d4](https://github.com/open-feature/java-sdk/commit/5c335d45393227cdeb3813630ee6ef9d4196916d)) +* **deps:** update github/codeql-action digest to 6a07b2a ([#502](https://github.com/open-feature/java-sdk/issues/502)) ([b0201c7](https://github.com/open-feature/java-sdk/commit/b0201c7d4311f4c4ababa91984cf937b48ec7d35)) +* **deps:** update github/codeql-action digest to 6bd8101 ([#454](https://github.com/open-feature/java-sdk/issues/454)) ([cc155b3](https://github.com/open-feature/java-sdk/commit/cc155b354c4978274d386eb769260f02535bc198)) +* **deps:** update github/codeql-action digest to 6cfb483 ([#439](https://github.com/open-feature/java-sdk/issues/439)) ([1af8e96](https://github.com/open-feature/java-sdk/commit/1af8e966a461d2eff40fdd3749df09e849339134)) +* **deps:** update github/codeql-action digest to 84c0579 ([#498](https://github.com/open-feature/java-sdk/issues/498)) ([10bee74](https://github.com/open-feature/java-sdk/commit/10bee74d16bba1bcaa110d58160e0f4eb9e7a960)) +* **deps:** update github/codeql-action digest to 85c77f1 ([#500](https://github.com/open-feature/java-sdk/issues/500)) ([4f6d7ff](https://github.com/open-feature/java-sdk/commit/4f6d7ff46d931c5f8bbdd454dda7c9b2c09578e8)) +* **deps:** update github/codeql-action digest to 8b0f2cf ([#462](https://github.com/open-feature/java-sdk/issues/462)) ([7f91942](https://github.com/open-feature/java-sdk/commit/7f9194231c6340a712a23b7298772fba3b4f4824)) +* **deps:** update github/codeql-action digest to 8ba77ef ([#485](https://github.com/open-feature/java-sdk/issues/485)) ([dac79f0](https://github.com/open-feature/java-sdk/commit/dac79f0bd5f856230a86b7bc3e3842db92a5f8b6)) +* **deps:** update github/codeql-action digest to 8ca5570 ([#415](https://github.com/open-feature/java-sdk/issues/415)) ([0de764d](https://github.com/open-feature/java-sdk/commit/0de764db19e793b81eeea345bcec8be6bc83b2b6)) +* **deps:** update github/codeql-action digest to 926a489 ([#460](https://github.com/open-feature/java-sdk/issues/460)) ([0b1315e](https://github.com/open-feature/java-sdk/commit/0b1315eaaf4cb36bfb6c45a31d337e3ae31c0ea5)) +* **deps:** update github/codeql-action digest to 95a5fda ([#504](https://github.com/open-feature/java-sdk/issues/504)) ([00c8120](https://github.com/open-feature/java-sdk/commit/00c812045926e627743ec5ff699acf6ea6797f8f)) +* **deps:** update github/codeql-action digest to 95cfca7 ([#427](https://github.com/open-feature/java-sdk/issues/427)) ([20628a2](https://github.com/open-feature/java-sdk/commit/20628a23054768238cdef503382ee6b3c6d34476)) +* **deps:** update github/codeql-action digest to 96f2840 ([#458](https://github.com/open-feature/java-sdk/issues/458)) ([401d7a8](https://github.com/open-feature/java-sdk/commit/401d7a8a5fe19835710eadce3fa88a2fcb0ee5c9)) +* **deps:** update github/codeql-action digest to 988e1bc ([#379](https://github.com/open-feature/java-sdk/issues/379)) ([9b77827](https://github.com/open-feature/java-sdk/commit/9b778277968851752bd569a09f6609f2cb3ffe48)) +* **deps:** update github/codeql-action digest to 98f7bbd ([#383](https://github.com/open-feature/java-sdk/issues/383)) ([037d611](https://github.com/open-feature/java-sdk/commit/037d61128e1e8a06a16d5ac899c2c92762baa4b3)) +* **deps:** update github/codeql-action digest to 9a866ed ([#395](https://github.com/open-feature/java-sdk/issues/395)) ([2ff65b8](https://github.com/open-feature/java-sdk/commit/2ff65b8344d0a3ffe6daebc3fb9b40ade21e2d7e)) +* **deps:** update github/codeql-action digest to 9d2dd7c ([#457](https://github.com/open-feature/java-sdk/issues/457)) ([e1a0432](https://github.com/open-feature/java-sdk/commit/e1a0432ae988c5311bc00008fd3e8687d3a3839f)) +* **deps:** update github/codeql-action digest to a2d725d ([#497](https://github.com/open-feature/java-sdk/issues/497)) ([2f028f6](https://github.com/open-feature/java-sdk/commit/2f028f699012fb160f156249ef9c85ecd8c2df13)) +* **deps:** update github/codeql-action digest to a42c0ca ([#496](https://github.com/open-feature/java-sdk/issues/496)) ([9ddc9f1](https://github.com/open-feature/java-sdk/commit/9ddc9f1cb2c85c2d096a493342e429120ff36e92)) +* **deps:** update github/codeql-action digest to a8affb0 ([#401](https://github.com/open-feature/java-sdk/issues/401)) ([c92cd2c](https://github.com/open-feature/java-sdk/commit/c92cd2ccfeb028a43637a545e56142305f76c833)) +* **deps:** update github/codeql-action digest to a9648ea ([#405](https://github.com/open-feature/java-sdk/issues/405)) ([a5f076b](https://github.com/open-feature/java-sdk/commit/a5f076b37c0cba94cc7ae22577abdba43ee011ea)) +* **deps:** update github/codeql-action digest to afdf30f ([#397](https://github.com/open-feature/java-sdk/issues/397)) ([b55ed6c](https://github.com/open-feature/java-sdk/commit/b55ed6cf7417f248b44d9ca86535deee7c80cfcc)) +* **deps:** update github/codeql-action digest to b8f204c ([#474](https://github.com/open-feature/java-sdk/issues/474)) ([d309d16](https://github.com/open-feature/java-sdk/commit/d309d1633018217e1c2fad8bff8f3b55706aa016)) +* **deps:** update github/codeql-action digest to bb28e7e ([#368](https://github.com/open-feature/java-sdk/issues/368)) ([5e648f6](https://github.com/open-feature/java-sdk/commit/5e648f6332f08c72a5e232bd6ae2171e6476a05e)) +* **deps:** update github/codeql-action digest to bcb460d ([#495](https://github.com/open-feature/java-sdk/issues/495)) ([a8e3410](https://github.com/open-feature/java-sdk/commit/a8e34100a02fdd102a605030b5be47796258ec23)) +* **deps:** update github/codeql-action digest to be2b53b ([#394](https://github.com/open-feature/java-sdk/issues/394)) ([28e191d](https://github.com/open-feature/java-sdk/commit/28e191d4231c6c04971724e3d88166260a96bef4)) +* **deps:** update github/codeql-action digest to c552617 ([#506](https://github.com/open-feature/java-sdk/issues/506)) ([40d1f0a](https://github.com/open-feature/java-sdk/commit/40d1f0a1d52ca09df2a0e6a5d39604fb8162a4f7)) +* **deps:** update github/codeql-action digest to c5f3f01 ([#404](https://github.com/open-feature/java-sdk/issues/404)) ([6898514](https://github.com/open-feature/java-sdk/commit/6898514fca1f4c97edd1453217b4b6d70d996803)) +* **deps:** update github/codeql-action digest to c6dff34 ([#481](https://github.com/open-feature/java-sdk/issues/481)) ([ea54bff](https://github.com/open-feature/java-sdk/commit/ea54bff9cc6a452fd6e329d0c3f2bad678e498a5)) +* **deps:** update github/codeql-action digest to ca6b925 ([#436](https://github.com/open-feature/java-sdk/issues/436)) ([468c42d](https://github.com/open-feature/java-sdk/commit/468c42d4e3902085cda852901097b5c197fd7906)) +* **deps:** update github/codeql-action digest to cdcdbb5 ([#463](https://github.com/open-feature/java-sdk/issues/463)) ([736cf24](https://github.com/open-feature/java-sdk/commit/736cf24cbf54680c7c9ce66b05ef74402743f899)) +* **deps:** update github/codeql-action digest to cff3d9e ([#486](https://github.com/open-feature/java-sdk/issues/486)) ([6cd588b](https://github.com/open-feature/java-sdk/commit/6cd588b87a091ba11ccf3db8b2f72ffffbde358b)) +* **deps:** update github/codeql-action digest to d944b34 ([#390](https://github.com/open-feature/java-sdk/issues/390)) ([519c32a](https://github.com/open-feature/java-sdk/commit/519c32a087e94376b9a245ad9c1a4fab360adfe2)) +* **deps:** update github/codeql-action digest to da583b0 ([#409](https://github.com/open-feature/java-sdk/issues/409)) ([5abe971](https://github.com/open-feature/java-sdk/commit/5abe971bdba796cfb435ee02e72179ae406a05f0)) +* **deps:** update github/codeql-action digest to dc04638 ([#392](https://github.com/open-feature/java-sdk/issues/392)) ([813c7e2](https://github.com/open-feature/java-sdk/commit/813c7e21ab933680f507dc077ceabbdbda9299e0)) +* **deps:** update github/codeql-action digest to dc81ae3 ([#367](https://github.com/open-feature/java-sdk/issues/367)) ([bac2af3](https://github.com/open-feature/java-sdk/commit/bac2af3033245db5bb5da18790f86e657a773686)) +* **deps:** update github/codeql-action digest to dcf71cf ([#411](https://github.com/open-feature/java-sdk/issues/411)) ([2df3205](https://github.com/open-feature/java-sdk/commit/2df3205c747a8f156e38f8510d3f95f49527f6a8)) +* **deps:** update github/codeql-action digest to de74ca6 ([#480](https://github.com/open-feature/java-sdk/issues/480)) ([bd3042b](https://github.com/open-feature/java-sdk/commit/bd3042ba0d15e0bd9a2f0d68693633adb555f6e2)) +* **deps:** update github/codeql-action digest to deb312c ([#422](https://github.com/open-feature/java-sdk/issues/422)) ([af3e3d6](https://github.com/open-feature/java-sdk/commit/af3e3d60dc12f37199e79a0f6dd5f7b065944a49)) +* **deps:** update github/codeql-action digest to e287d85 ([#472](https://github.com/open-feature/java-sdk/issues/472)) ([fa94c0e](https://github.com/open-feature/java-sdk/commit/fa94c0e0ddbcb0bf5e6af7d1b6f53c1b885d7270)) +* **deps:** update github/codeql-action digest to ed6c499 ([#386](https://github.com/open-feature/java-sdk/issues/386)) ([f1ecfac](https://github.com/open-feature/java-sdk/commit/f1ecfac6aaac1102bd380a25935a42c64eda441b)) +* **deps:** update github/codeql-action digest to f0a422f ([#373](https://github.com/open-feature/java-sdk/issues/373)) ([6a8c911](https://github.com/open-feature/java-sdk/commit/6a8c911287d8b3d2e35f6455af3496a532a71553)) +* **deps:** update github/codeql-action digest to f31a31c ([#412](https://github.com/open-feature/java-sdk/issues/412)) ([be9d652](https://github.com/open-feature/java-sdk/commit/be9d6523ff0cb3d42e68d8f4d36fe9661ec25eca)) +* **deps:** update github/codeql-action digest to f32426b ([#378](https://github.com/open-feature/java-sdk/issues/378)) ([ae30789](https://github.com/open-feature/java-sdk/commit/ae307892a5fbc9ac02db47e42acc1a723b714938)) +* **deps:** update github/codeql-action digest to f8b1cb6 ([#453](https://github.com/open-feature/java-sdk/issues/453)) ([1dddd68](https://github.com/open-feature/java-sdk/commit/1dddd68c4243a8823bb1b92091d3b25871e50ed8)) +* **deps:** update github/codeql-action digest to fa7cce4 ([#376](https://github.com/open-feature/java-sdk/issues/376)) ([23c4c4c](https://github.com/open-feature/java-sdk/commit/23c4c4cef9ff0d18aec44af5c0c808439124d142)) +* **deps:** update github/codeql-action digest to fff3a80 ([#365](https://github.com/open-feature/java-sdk/issues/365)) ([3ae2a54](https://github.com/open-feature/java-sdk/commit/3ae2a541a1c8a9fc568a97aa02301df1353e092b)) +* **deps:** update google-github-actions/release-please-action digest to 01f98cb ([#489](https://github.com/open-feature/java-sdk/issues/489)) ([7f01ded](https://github.com/open-feature/java-sdk/commit/7f01deda5b5fb20ca126019e8553c4ac10ce460f)) +* **deps:** update google-github-actions/release-please-action digest to 51ee8ae ([#452](https://github.com/open-feature/java-sdk/issues/452)) ([58df782](https://github.com/open-feature/java-sdk/commit/58df782b767617c63628ddb9ece3ed3816d865ad)) +* **deps:** update google-github-actions/release-please-action digest to 8475937 ([#406](https://github.com/open-feature/java-sdk/issues/406)) ([cd27e38](https://github.com/open-feature/java-sdk/commit/cd27e38f676417e37f7a75cc8413b42350c088cc)) +* **deps:** update google-github-actions/release-please-action digest to c078ea3 ([#387](https://github.com/open-feature/java-sdk/issues/387)) ([702957c](https://github.com/open-feature/java-sdk/commit/702957c517345906db80c0805e02e22ee18fa70c)) +* **deps:** update google-github-actions/release-please-action digest to ee9822e ([#366](https://github.com/open-feature/java-sdk/issues/366)) ([6d7c43d](https://github.com/open-feature/java-sdk/commit/6d7c43d120d025d180a446ba7769109b94e1be3c)) +* **deps:** update google-github-actions/release-please-action digest to f7edb9e ([#384](https://github.com/open-feature/java-sdk/issues/384)) ([22828d1](https://github.com/open-feature/java-sdk/commit/22828d1d3f59371205d36b8419dd61647046043f)) +* expose get value for metadata ([#468](https://github.com/open-feature/java-sdk/issues/468)) ([93dde1d](https://github.com/open-feature/java-sdk/commit/93dde1d259e86b00db701a753b84ad2c253e21ec)) +* rename flag metadata ([#478](https://github.com/open-feature/java-sdk/issues/478)) ([ecfeddf](https://github.com/open-feature/java-sdk/commit/ecfeddf0f67c4d9cf34530f957d139344b622b51)) +* rename integration tests e2e ([#417](https://github.com/open-feature/java-sdk/issues/417)) ([a5c93ac](https://github.com/open-feature/java-sdk/commit/a5c93aca0a718a5760bc346f27fd70b59432d11a)) +* seperate release plugins to a profile ([#467](https://github.com/open-feature/java-sdk/issues/467)) ([31f2148](https://github.com/open-feature/java-sdk/commit/31f214826453a10d7bef2d1d59033febf75dbb76)) +* update copy and links on the readme ([#488](https://github.com/open-feature/java-sdk/issues/488)) ([6cd2081](https://github.com/open-feature/java-sdk/commit/6cd208198ce786ce173eea2dbcffb6338ba28c86)) +* update readme for events ([#507](https://github.com/open-feature/java-sdk/issues/507)) ([c115e96](https://github.com/open-feature/java-sdk/commit/c115e96ae67ce7d006d8ee495685d07895c06774)) +* update readme using template ([#382](https://github.com/open-feature/java-sdk/issues/382)) ([f51d020](https://github.com/open-feature/java-sdk/commit/f51d0201c62b558a89a1e3ab77e666ce98ecba0b)) + ## [1.3.1](https://github.com/open-feature/java-sdk/compare/v1.3.0...v1.3.1) (2023-03-28) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84c8d017..b7ffa9a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -Welcome! Super happy to have you here. +## Welcome! Super happy to have you here. A few things. @@ -10,10 +10,26 @@ We're not keen on vendor-specific stuff in this library, but if there are change Any contributions you make are expected to be tested with unit tests. You can validate these work with `gradle test`, or the automation itself will run them for you when you make a PR. -Your code is supposed to work with Java 11+. +Your code is supposed to work with Java 8+. If you think we might be out of date with the spec, you can check that by invoking `python spec_finder.py` in the root of the repository. This will validate we have tests defined for all of the specification entries we know about. If you're adding tests to cover something in the spec, use the `@Specification` annotation like you see throughout the test suites. +## End-to-End Tests + +The continuous integration runs a set of [gherkin e2e tests](https://github.com/open-feature/test-harness/blob/main/features/evaluation.feature) using [`flagd`](https://github.com/open-feature/flagd). These tests do not run with the default maven profile. If you'd like to run them locally, you can start the flagd testbed with + +``` +docker run -p 8013:8013 ghcr.io/open-feature/flagd-testbed:latest +``` +and then run +``` +mvn test -P e2e-test +``` + +## Releasing + +See [releasing](./docs/release.md). + Thanks and looking forward to your issues and pull requests. diff --git a/README.md b/README.md index 1b135dfb..8ef4a053 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,49 @@ -# OpenFeature SDK for Java - + +

+ + + + OpenFeature Logo + +

+ +

OpenFeature Java SDK

+ +[![Specification](https://img.shields.io/static/v1?label=Specification&message=v0.6.0&color=yellow)](https://github.com/open-feature/spec/tree/v0.6.0) [![Maven Central](https://maven-badges.herokuapp.com/maven-central/dev.openfeature/sdk/badge.svg)](https://maven-badges.herokuapp.com/maven-central/dev.openfeature/sdk) [![javadoc](https://javadoc.io/badge2/dev.openfeature/sdk/javadoc.svg)](https://javadoc.io/doc/dev.openfeature/sdk) [![Project Status: Active โ€“ The project has reached a stable, usable state and is being actively developed.](https://www.repostatus.org/badges/latest/active.svg)](https://www.repostatus.org/#active) -[![Specification](https://img.shields.io/static/v1?label=Specification&message=v0.5.2&color=yellow)](https://github.com/open-feature/spec/tree/v0.5.2) [![Known Vulnerabilities](https://snyk.io/test/github/open-feature/java-sdk/badge.svg)](https://snyk.io/test/github/open-feature/java-sdk) [![on-merge](https://github.com/open-feature/java-sdk/actions/workflows/merge.yml/badge.svg)](https://github.com/open-feature/java-sdk/actions/workflows/merge.yml) [![codecov](https://codecov.io/gh/open-feature/java-sdk/branch/main/graph/badge.svg?token=XMS9L7PBY1)](https://codecov.io/gh/open-feature/java-sdk) [![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/6241/badge)](https://bestpractices.coreinfrastructure.org/projects/6241) -This is the Java implementation of [OpenFeature](https://openfeature.dev), a vendor-agnostic abstraction library for evaluating feature flags. - -We support multiple data types for flags (numbers, strings, booleans, objects) as well as hooks, which can alter the lifecycle of a flag evaluation. - -This library is intended to be used in server-side contexts and has not been evaluated for use in mobile devices. +## ๐Ÿ‘‹ Hey there! Thanks for checking out the OpenFeature Java SDK -## Usage +### What is OpenFeature? -While `Boolean` provides the simplest introduction, we offer a variety of flag types. +[OpenFeature][openfeature-website] is an open standard that provides a vendor-agnostic, community-driven API for feature flagging that works with your favorite feature flag management tool. -```java -import dev.openfeature.sdk.Structure; - -class MyClass { - public UI booleanExample() { - // Should we render the redesign? Or the default webpage? - if (client.getBooleanValue("redesign_enabled", false)) { - return render_redesign(); - } - return render_normal(); - } - - public Template stringExample() { - // Get the template to load for the custom new homepage - String template = client.getStringValue("homepage_template", "default-homepage.html"); - return render_template(template); - } - - public List numberExample() { - // How many modules should we be fetching? - Integer count = client.getIntegerValue("module-fetch-count", 4); - return fetch_modules(count); - } +### Why standardize feature flags? - public HomepageModule structureExample() { - Structure obj = client.getObjectValue("hero-module", previouslyDefinedDefaultStructure); - return HomepageModule.builder() - .title(obj.getValue("title")) - .body(obj.getValue("description")) - .build(); - } -} -``` +Standardizing feature flags unifies tools and vendors behind a common interface which avoids vendor lock-in at the code level. Additionally, it offers a framework for building extensions and integrations and allows providers to focus on their unique value proposition. -For complete documentation, visit: https://docs.openfeature.dev/docs/category/concepts +## ๐Ÿ” Requirements -## Requirements - Java 8+ (compiler target is 1.8) -## Installation +Note that this library is intended to be used in server-side contexts and has not been evaluated for use in mobile devices. -### Add it to your build +## ๐Ÿ“ฆ Installation + +### Maven -#### Maven ```xml dev.openfeature sdk - 1.3.1 + 1.4.0 ``` @@ -88,60 +63,209 @@ If you would like snapshot builds, this is the relevant repository information: ``` -#### Gradle +### Gradle ```groovy dependencies { - implementation 'dev.openfeature:sdk:1.3.1' + implementation 'dev.openfeature:sdk:1.4.0' } ``` -### Configure it -To configure it, you'll need to add a provider to the global singleton `OpenFeatureAPI`. From there, you can generate a `Client` which is usable by your code. While you'll likely want a provider for your specific backend, we've provided a `NoOpProvider`, which simply returns the default passed in. +### Software Bill of Materials (SBOM) + +We publish SBOMs with all of our releases as of 0.3.0. You can find them in Maven Central alongside the artifacts. + +## ๐ŸŒŸ Features + +- support for various backend [providers](https://openfeature.dev/docs/reference/concepts/provider) +- easy integration and extension via [hooks](https://openfeature.dev/docs/reference/concepts/hooks) +- bool, string, numeric, and object flag types +- [context-aware](https://openfeature.dev/docs/reference/concepts/evaluation-context) evaluation + +## ๐Ÿš€ Usage + ```java -class MyApp { - public void example(){ - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(new NoOpProvider()); - Client client = api.getClient(); - // Now use your `client` instance to evaluate some feature flags! - } +public void example(){ + + // configure a provider + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.setProvider(new MyProviderOfChoice()); + + // create a client + Client client = api.getClient(); + + // get a bool flag value + boolean flagValue = client.getBooleanValue("boolFlag", false); } ``` -## Contacting us -We hold regular meetings which you can see [here](https://github.com/open-feature/community/#meetings-and-events). -We are also present on the `#openfeature` channel in the [CNCF slack](https://slack.cncf.io/). +### Context-aware evaluation + +Sometimes the value of a flag must take into account some dynamic criteria about the application or user, such as the user location, IP, email address, or the location of the server. +In OpenFeature, we refer to this as [`targeting`](https://openfeature.dev/specification/glossary#targeting). +If the flag system you're using supports targeting, you can provide the input data using the `EvaluationContext`. -## Developing +```java +// global context for static data +OpenFeatureAPI api = OpenFeatureAPI.getInstance(); +Map attributes = new HashMap<>(); +attributes.put("appVersion", new Value(System.getEnv("APP_VERSION"))); +EvaluationContext apiCtx = new ImmutableContext(attributes); +api.setEvaluationContext(apiCtx); + +// request context +Map attributes = new HashMap<>(); +attributes.put("email", new Value(session.getAttribute("email"))); +attributes.put("product", new Value(productId)); +String targetingKey = session.getId(); +EvaluationContext reqCtx = new ImmutableContext(targetingKey, attributes); + +// use merged contextual data to determine a flag value +boolean flagValue = client.getBooleanValue("some-flag", false, reqCtx); +``` -### Integration tests +### Events -The continuous integration runs a set of [gherkin integration tests](https://github.com/open-feature/test-harness/blob/main/features/evaluation.feature) using [`flagd`](https://github.com/open-feature/flagd). These tests do not run with the default maven profile. If you'd like to run them locally, you can start the flagd testbed with +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. +```java +// add an event handler to a client +client.onProviderConfigurationChanged((EventDetails eventDetails) -> { + // do something when the provider's flag settings change +}); + +// add an event handler to the global API +OpenFeatureAPI.getInstance().onProviderStale((EventDetails eventDetails) -> { + // do something when the provider's cache goes stale +}); ``` -docker run -p 8013:8013 ghcr.io/open-feature/flagd-testbed:latest + +### Hooks + +A hook is a mechanism that allows for adding arbitrary behavior at well-defined points of the flag evaluation life-cycle. +Use cases include validating the resolved flag value, modifying or adding data to the evaluation context, logging, telemetry, and tracking. + +```java +public class MyHook implements Hook { + /** + * + * @param ctx Information about the particular flag evaluation + * @param details Information about how the flag was resolved, including any resolved values. + * @param hints An immutable mapping of data for users to communicate to the hooks. + */ + @Override + public void after(HookContext ctx, FlagEvaluationDetails details, Map hints) { + System.out.println("After evaluation!"); + } +} ``` -and then run + +See [here](https://openfeature.dev/ecosystem?instant_search%5BrefinementList%5D%5Btype%5D%5B0%5D=Hook&instant_search%5BrefinementList%5D%5Btechnology%5D%5B0%5D=Java) for a catalog of available hooks. + +### Logging: + +The Java SDK uses SLF4J. See the [SLF4J manual](https://slf4j.org/manual.html) for complete documentation. + +### Named clients + +Clients can be given a name. +A name is a logical identifier which can be used to associate clients with a particular provider. +If a name has no associated provider, clients with that name use the global provider. + +```java +FeatureProvider scopedProvider = new MyProvider(); + +// set this provider for clients named "my-name" +OpenFeatureAPI.getInstance().setProvider("my-name", provider); + +// create a client bound to the provider above +Client client = OpenFeatureAPI.getInstance().getClient("my-name"); ``` -mvn test -P integration-test + +### Providers: + +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/java-sdk-contrib) available under the OpenFeature organization. +Finally, youโ€™ll then need to write the provider itself. +This can be accomplished by implementing the `FeatureProvider` interface exported by the OpenFeature SDK. + +```java +public class MyProvider implements FeatureProvider { +@Override + public Metadata getMetadata() { + return () -> "My Provider"; + } + + @Override + public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { + // resolve a boolean flag value + } + + @Override + public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { + // resolve a string flag value + } + + @Override + public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { + // resolve an int flag value + } + + @Override + public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { + // resolve a double flag value + } + + @Override + public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { + // resolve an object flag value + } +} ``` -## Releasing +See [here](https://openfeature.dev/ecosystem?instant_search%5BrefinementList%5D%5Btype%5D%5B0%5D=Provider&instant_search%5BrefinementList%5D%5Btechnology%5D%5B0%5D=Java) for a catalog of available providers. -See [releasing](./docs/release.md). +### Shutdown -### Software Bill of Materials (SBOM) +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. -We publish SBOMs with all of our releases as of 0.3.0. You can find them in Maven Central alongside the artifacts. +```java +// shut down all providers +OpenFeatureAPI.getInstance().shutdown(); +``` -## Contributors +### Complete API documentation: -Thanks so much to our contributors. +See [here](https://www.javadoc.io/doc/dev.openfeature/sdk/latest/index.html) for the complete API documentation. + +## โญ๏ธ 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, 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 that has already contributed - Pictures of the folks who have contributed to the project + Pictures of the folks who have contributed to the project Made with [contrib.rocks](https://contrib.rocks). + +## ๐Ÿ“œ License + +[Apache License 2.0](LICENSE) + +[openfeature-website]: https://openfeature.dev diff --git a/pom.xml b/pom.xml index 7dee3aae..3ec58097 100644 --- a/pom.xml +++ b/pom.xml @@ -4,15 +4,15 @@ dev.openfeature sdk - 1.3.1 + 1.4.0 UTF-8 1.8 ${maven.compiler.source} - 5.9.2 - - **/integration/*.java + 5.9.3 + + **/e2e/*.java ${groupId}.${artifactId} @@ -45,7 +45,7 @@ org.projectlombok lombok - 1.18.26 + 1.18.28 provided @@ -109,7 +109,7 @@ org.junit.platform junit-platform-suite - 1.9.2 + 1.9.3 test @@ -135,14 +135,21 @@ com.google.guava guava - 31.1-jre + 32.1.1-jre test dev.openfeature.contrib.providers flagd - 0.5.7 + 0.5.10 + test + + + + org.awaitility + awaitility + 4.2.0 test @@ -153,7 +160,7 @@ io.cucumber cucumber-bom - 7.11.2 + 7.13.0 pom import @@ -161,7 +168,7 @@ org.junit junit-bom - 5.9.2 + 5.9.3 pom import @@ -171,26 +178,10 @@ - - - org.codehaus.mojo - build-helper-maven-plugin - 3.3.0 - - - validate - get-cpu-count - - cpu-count - - - - - org.cyclonedx cyclonedx-maven-plugin - 2.7.5 + 2.7.9 library 1.3 @@ -215,7 +206,7 @@ maven-dependency-plugin - 3.5.0 + 3.6.0 verify @@ -250,14 +241,11 @@ org.apache.maven.plugins maven-surefire-plugin - 3.0.0 + 3.1.2 ${surefireArgLine} - - ${cpu.count} - false ${testExclusions} @@ -268,7 +256,7 @@ org.apache.maven.plugins maven-failsafe-plugin - 3.0.0 + 3.1.2 ${surefireArgLine} @@ -279,7 +267,7 @@ org.jacoco jacoco-maven-plugin - 0.8.8 + 0.8.10 @@ -336,20 +324,6 @@ - - - org.sonatype.plugins - nexus-staging-maven-plugin - 1.6.13 - true - - ossrh - https://s01.oss.sonatype.org/ - true - - - - org.apache.maven.plugins maven-jar-plugin @@ -362,61 +336,11 @@ - - - org.apache.maven.plugins - maven-source-plugin - 3.2.1 - - - attach-sources - - jar-no-fork - - - - - - - org.apache.maven.plugins - maven-javadoc-plugin - 3.5.0 - - true - all,-missing - - - - attach-javadocs - - jar - - - - - - - - - org.apache.maven.plugins - maven-gpg-plugin - 3.0.1 - - - sign-artifacts - install - - sign - - - - - org.apache.maven.plugins maven-pmd-plugin - 3.20.0 + 3.21.0 run-pmd @@ -431,7 +355,7 @@ com.github.spotbugs spotbugs-maven-plugin - 4.7.3.3 + 4.7.3.5 spotbugs-exclusions.xml @@ -464,7 +388,7 @@ org.apache.maven.plugins maven-checkstyle-plugin - 3.2.1 + 3.3.0 checkstyle.xml UTF-8 @@ -489,16 +413,93 @@ - + - - integration-test + deploy + + true + + + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.6.13 + true + + ossrh + https://s01.oss.sonatype.org/ + true + + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.0 + + + attach-sources + + jar-no-fork + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.5.0 + + true + all,-missing + + + + attach-javadocs + + jar + + + + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.1.0 + + + sign-artifacts + install + + sign + + + + + + + + + + + + + e2e-test - + diff --git a/src/main/java/dev/openfeature/sdk/Client.java b/src/main/java/dev/openfeature/sdk/Client.java index a4ccf26f..ebca0b13 100644 --- a/src/main/java/dev/openfeature/sdk/Client.java +++ b/src/main/java/dev/openfeature/sdk/Client.java @@ -5,7 +5,7 @@ /** * Interface used to resolve flags of varying types. */ -public interface Client extends Features { +public interface Client extends Features, EventBus { Metadata getMetadata(); /** diff --git a/src/main/java/dev/openfeature/sdk/EventBus.java b/src/main/java/dev/openfeature/sdk/EventBus.java new file mode 100644 index 00000000..d635e9ba --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/EventBus.java @@ -0,0 +1,64 @@ +package dev.openfeature.sdk; + +import java.util.function.Consumer; + +/** + * Interface for attaching event handlers. + */ +public interface EventBus { + + /** + * Add a handler for the {@link ProviderEvent#PROVIDER_READY} event. + * Shorthand for {@link #on(ProviderEvent, Consumer)} + * + * @param handler behavior to add with this event + * @return this + */ + T onProviderReady(Consumer handler); + + /** + * Add a handler for the {@link ProviderEvent#PROVIDER_CONFIGURATION_CHANGED} event. + * Shorthand for {@link #on(ProviderEvent, Consumer)} + * + * @param handler behavior to add with this event + * @return this + */ + T onProviderConfigurationChanged(Consumer handler); + + /** + * Add a handler for the {@link ProviderEvent#PROVIDER_STALE} event. + * Shorthand for {@link #on(ProviderEvent, Consumer)} + * + * @param handler behavior to add with this event + * @return this + */ + T onProviderError(Consumer handler); + + /** + * Add a handler for the {@link ProviderEvent#PROVIDER_ERROR} event. + * Shorthand for {@link #on(ProviderEvent, Consumer)} + * + * @param handler behavior to add with this event + * @return this + */ + T onProviderStale(Consumer handler); + + /** + * Add a handler for the specified {@link ProviderEvent}. + * + * @param event event type + * @param handler behavior to add with this event + * @return this + */ + T on(ProviderEvent event, Consumer handler); + + /** + * Remove the previously attached handler by reference. + * If the handler doesn't exists, no-op. + * + * @param event event type + * @param handler to be removed + * @return this + */ + T removeHandler(ProviderEvent event, Consumer handler); +} diff --git a/src/main/java/dev/openfeature/sdk/EventDetails.java b/src/main/java/dev/openfeature/sdk/EventDetails.java new file mode 100644 index 00000000..3f6db159 --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/EventDetails.java @@ -0,0 +1,28 @@ +package dev.openfeature.sdk; + +import edu.umd.cs.findbugs.annotations.Nullable; +import lombok.Data; +import lombok.experimental.SuperBuilder; + +/** + * The details of a particular event. + */ +@Data @SuperBuilder(toBuilder = true) +public class EventDetails extends ProviderEventDetails { + private String clientName; + + static EventDetails fromProviderEventDetails(ProviderEventDetails providerEventDetails) { + return EventDetails.fromProviderEventDetails(providerEventDetails, null); + } + + static EventDetails fromProviderEventDetails( + ProviderEventDetails providerEventDetails, + @Nullable String clientName) { + return EventDetails.builder() + .clientName(clientName) + .flagsChanged(providerEventDetails.getFlagsChanged()) + .eventMetadata(providerEventDetails.getEventMetadata()) + .message(providerEventDetails.getMessage()) + .build(); + } +} diff --git a/src/main/java/dev/openfeature/sdk/EventProvider.java b/src/main/java/dev/openfeature/sdk/EventProvider.java new file mode 100644 index 00000000..de12b077 --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/EventProvider.java @@ -0,0 +1,95 @@ +package dev.openfeature.sdk; + +import dev.openfeature.sdk.internal.TriConsumer; + +/** + * Abstract EventProvider. Providers must extend this class to support events. + * Emit events with {@link #emit(ProviderEvent, ProviderEventDetails)}. Please + * note that the SDK will automatically emit + * {@link ProviderEvent#PROVIDER_READY } or + * {@link ProviderEvent#PROVIDER_ERROR } accordingly when + * {@link FeatureProvider#initialize(EvaluationContext)} completes successfully + * or with error, so these events need not be emitted manually during + * initialization. + * + * @see FeatureProvider + */ +public abstract class EventProvider implements FeatureProvider { + + private TriConsumer onEmit = null; + + /** + * "Attach" this EventProvider to an SDK, which allows events to propagate from this provider. + * No-op if the same onEmit is already attached. + * + * @param onEmit the function to run when a provider emits events. + */ + void attach(TriConsumer onEmit) { + if (this.onEmit != null && this.onEmit != onEmit) { + // if we are trying to attach this provider to a different onEmit, something has gone wrong + throw new IllegalStateException("Provider " + this.getMetadata().getName() + " is already attached."); + } else { + this.onEmit = onEmit; + } + } + + /** + * "Detach" this EventProvider from an SDK, stopping propagation of all events. + */ + void detach() { + this.onEmit = null; + } + + /** + * Emit the specified {@link ProviderEvent}. + * + * @param event The event type + * @param details The details of the event + */ + public void emit(ProviderEvent event, ProviderEventDetails details) { + if (this.onEmit != null) { + this.onEmit.accept(this, event, details); + } + } + + /** + * Emit a {@link ProviderEvent#PROVIDER_READY} event. + * Shorthand for {@link #emit(ProviderEvent, ProviderEventDetails)} + * + * @param details The details of the event + */ + public void emitProviderReady(ProviderEventDetails details) { + emit(ProviderEvent.PROVIDER_READY, details); + } + + /** + * Emit a + * {@link ProviderEvent#PROVIDER_CONFIGURATION_CHANGED} + * event. Shorthand for {@link #emit(ProviderEvent, ProviderEventDetails)} + * + * @param details The details of the event + */ + public void emitProviderConfigurationChanged(ProviderEventDetails details) { + emit(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, details); + } + + /** + * Emit a {@link ProviderEvent#PROVIDER_STALE} event. + * Shorthand for {@link #emit(ProviderEvent, ProviderEventDetails)} + * + * @param details The details of the event + */ + public void emitProviderStale(ProviderEventDetails details) { + emit(ProviderEvent.PROVIDER_STALE, details); + } + + /** + * Emit a {@link ProviderEvent#PROVIDER_ERROR} event. + * Shorthand for {@link #emit(ProviderEvent, ProviderEventDetails)} + * + * @param details The details of the event + */ + public void emitProviderError(ProviderEventDetails details) { + emit(ProviderEvent.PROVIDER_ERROR, details); + } +} diff --git a/src/main/java/dev/openfeature/sdk/EventSupport.java b/src/main/java/dev/openfeature/sdk/EventSupport.java new file mode 100644 index 00000000..6558f969 --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/EventSupport.java @@ -0,0 +1,174 @@ +package dev.openfeature.sdk; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Consumer; + +import edu.umd.cs.findbugs.annotations.Nullable; +import lombok.extern.slf4j.Slf4j; + +/** + * Util class for storing and running handlers. + */ +@Slf4j +class EventSupport { + + // we use a v4 uuid as a "placeholder" for anonymous clients, since + // ConcurrentHashMap doesn't support nulls + private static final String defaultClientUuid = UUID.randomUUID().toString(); + private static final ExecutorService taskExecutor = Executors.newCachedThreadPool(); + private final Map handlerStores = new ConcurrentHashMap<>(); + private final HandlerStore globalHandlerStore = new HandlerStore(); + + /** + * Run all the event handlers associated with this client name. + * If the client name is null, handlers attached to unnamed clients will run. + * + * @param clientName the client name to run event handlers for, or null + * @param event the event type + * @param eventDetails the event details + */ + public void runClientHandlers(@Nullable String clientName, ProviderEvent event, EventDetails eventDetails) { + clientName = Optional.ofNullable(clientName) + .orElse(defaultClientUuid); + + // run handlers if they exist + Optional.ofNullable(handlerStores.get(clientName)) + .filter(store -> Optional.of(store).isPresent()) + .map(store -> store.handlerMap.get(event)) + .ifPresent(handlers -> handlers + .forEach(handler -> runHandler(handler, eventDetails))); + } + + /** + * Run all the API (global) event handlers. + * + * @param event the event type + * @param eventDetails the event details + */ + public void runGlobalHandlers(ProviderEvent event, EventDetails eventDetails) { + globalHandlerStore.handlerMap.get(event) + .forEach(handler -> { + runHandler(handler, eventDetails); + }); + } + + /** + * Add a handler for the specified client name, or all unnamed clients. + * + * @param clientName the client name to add handlers for, or else the unnamed + * client + * @param event the event type + * @param handler the handler function to run + */ + public void addClientHandler(@Nullable String clientName, ProviderEvent event, Consumer handler) { + final String name = Optional.ofNullable(clientName) + .orElse(defaultClientUuid); + + // lazily create and cache a HandlerStore if it doesn't exist + HandlerStore store = Optional.ofNullable(this.handlerStores.get(name)) + .orElseGet(() -> { + HandlerStore newStore = new HandlerStore(); + this.handlerStores.put(name, newStore); + return newStore; + }); + store.addHandler(event, handler); + } + + /** + * Remove a client event handler for the specified event type. + * + * @param clientName the name of the client handler to remove, or null to remove + * from unnamed clients + * @param event the event type + * @param handler the handler ref to be removed + */ + public void removeClientHandler(String clientName, ProviderEvent event, Consumer handler) { + clientName = Optional.ofNullable(clientName) + .orElse(defaultClientUuid); + this.handlerStores.get(clientName).removeHandler(event, handler); + } + + /** + * Add a global event handler of the specified event type. + * + * @param event the event type + * @param handler the handler to be added + */ + public void addGlobalHandler(ProviderEvent event, Consumer handler) { + this.globalHandlerStore.addHandler(event, handler); + } + + /** + * Remove a global event handler for the specified event type. + * + * @param event the event type + * @param handler the handler ref to be removed + */ + public void removeGlobalHandler(ProviderEvent event, Consumer handler) { + this.globalHandlerStore.removeHandler(event, handler); + } + + /** + * Get all client names for which we have event handlers registered. + * + * @return set of client names + */ + public Set getAllClientNames() { + return this.handlerStores.keySet(); + } + + /** + * Run the passed handler on the taskExecutor. + * + * @param handler the handler to run + * @param eventDetails the event details + */ + public void runHandler(Consumer handler, EventDetails eventDetails) { + taskExecutor.submit(() -> { + try { + handler.accept(eventDetails); + } catch (Exception e) { + log.error("Exception in event handler {}", handler, e); + } + }); + } + + /** + * Stop the event handler task executor. + */ + public void shutdown() { + taskExecutor.shutdown(); + } + + // Handler store maintains a set of handlers for each event type. + // Each client in the SDK gets it's own handler store, which is lazily + // instantiated when a handler is added to that client. + static class HandlerStore { + + private final Map>> handlerMap; + + { + handlerMap = new ConcurrentHashMap>>(); + handlerMap.put(ProviderEvent.PROVIDER_READY, new ArrayList<>()); + handlerMap.put(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, new ArrayList<>()); + handlerMap.put(ProviderEvent.PROVIDER_ERROR, new ArrayList<>()); + handlerMap.put(ProviderEvent.PROVIDER_STALE, new ArrayList<>()); + } + + void addHandler(ProviderEvent event, Consumer handler) { + handlerMap.get(event).add(handler); + } + + void removeHandler(ProviderEvent event, Consumer handler) { + handlerMap.get(event).remove(handler); + } + } +} diff --git a/src/main/java/dev/openfeature/sdk/FeatureProvider.java b/src/main/java/dev/openfeature/sdk/FeatureProvider.java index 77e9cd67..933166fa 100644 --- a/src/main/java/dev/openfeature/sdk/FeatureProvider.java +++ b/src/main/java/dev/openfeature/sdk/FeatureProvider.java @@ -4,7 +4,9 @@ import java.util.List; /** - * The interface implemented by upstream flag providers to resolve flags for their service. + * The interface implemented by upstream flag providers to resolve flags for + * their service. If you want to support realtime events with your provider, you + * should extend {@link EventProvider} */ public interface FeatureProvider { Metadata getMetadata(); @@ -22,4 +24,45 @@ default List getProviderHooks() { ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx); ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx); + + /** + * 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. + *

+ * It is ok, if the method is expensive as it is executed in the background. All + * runtime exceptions will be + * caught and logged. + *

+ */ + default void initialize(EvaluationContext evaluationContext) throws Exception { + // Intentionally left blank + } + + /** + * 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. + *

+ * It is ok, if the method is expensive as it is executed in the background. All + * runtime exceptions will be + * caught and logged. + *

+ */ + default void shutdown() { + // Intentionally left blank + } + + /** + * Returns a representation of the current readiness of the provider. + * Providers which do not implement this method are assumed to be ready immediately. + * + * @return ProviderState + */ + default ProviderState getState() { + return ProviderState.READY; + } + } diff --git a/src/main/java/dev/openfeature/sdk/FlagEvaluationDetails.java b/src/main/java/dev/openfeature/sdk/FlagEvaluationDetails.java index 67ee853d..78e04c71 100644 --- a/src/main/java/dev/openfeature/sdk/FlagEvaluationDetails.java +++ b/src/main/java/dev/openfeature/sdk/FlagEvaluationDetails.java @@ -1,29 +1,37 @@ package dev.openfeature.sdk; +import javax.annotation.Nullable; + +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; - -import javax.annotation.Nullable; +import lombok.NoArgsConstructor; /** - * Contains information about how the evaluation happened, including any resolved values. + * Contains information about how the provider resolved a flag, including the resolved value. + * * @param the type of the flag being evaluated. */ -@Data @Builder +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor public class FlagEvaluationDetails implements BaseEvaluation { + private String flagKey; private T value; @Nullable private String variant; @Nullable private String reason; private ErrorCode errorCode; @Nullable private String errorMessage; + @Builder.Default private ImmutableMetadata flagMetadata = ImmutableMetadata.builder().build(); /** * Generate detail payload from the provider response. * * @param providerEval provider response - * @param flagKey key for the flag being evaluated - * @param type of flag being returned + * @param flagKey key for the flag being evaluated + * @param type of flag being returned * @return detail payload */ public static FlagEvaluationDetails from(ProviderEvaluation providerEval, String flagKey) { @@ -33,6 +41,7 @@ public static FlagEvaluationDetails from(ProviderEvaluation providerEv .variant(providerEval.getVariant()) .reason(providerEval.getReason()) .errorCode(providerEval.getErrorCode()) + .flagMetadata(providerEval.getFlagMetadata()) .build(); } } diff --git a/src/main/java/dev/openfeature/sdk/ImmutableMetadata.java b/src/main/java/dev/openfeature/sdk/ImmutableMetadata.java new file mode 100644 index 00000000..1bc130b4 --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/ImmutableMetadata.java @@ -0,0 +1,191 @@ +package dev.openfeature.sdk; + +import lombok.extern.slf4j.Slf4j; + +import java.util.HashMap; +import java.util.Map; + +/** + * Immutable Flag Metadata representation. Implementation is backed by a {@link Map} and immutability is provided + * through builder and accessors. + */ +@Slf4j +public class ImmutableMetadata { + private final Map metadata; + + private ImmutableMetadata(Map metadata) { + this.metadata = metadata; + } + + /** + * Retrieve a {@link String} value for the given key. A {@code null} value is returned if the key does not exist + * or if the value is of a different type. + * + * @param key flag metadata key to retrieve + */ + public String getString(final String key) { + return getValue(key, String.class); + } + + /** + * Retrieve a {@link Integer} value for the given key. A {@code null} value is returned if the key does not exist + * or if the value is of a different type. + * + * @param key flag metadata key to retrieve + */ + public Integer getInteger(final String key) { + return getValue(key, Integer.class); + } + + /** + * Retrieve a {@link Long} value for the given key. A {@code null} value is returned if the key does not exist + * or if the value is of a different type. + * + * @param key flag metadata key to retrieve + */ + public Long getLong(final String key) { + return getValue(key, Long.class); + } + + /** + * Retrieve a {@link Float} value for the given key. A {@code null} value is returned if the key does not exist + * or if the value is of a different type. + * + * @param key flag metadata key to retrieve + */ + public Float getFloat(final String key) { + return getValue(key, Float.class); + } + + /** + * Retrieve a {@link Double} value for the given key. A {@code null} value is returned if the key does not exist + * or if the value is of a different type. + * + * @param key flag metadata key to retrieve + */ + public Double getDouble(final String key) { + return getValue(key, Double.class); + } + + /** + * Retrieve a {@link Boolean} value for the given key. A {@code null} value is returned if the key does not exist + * or if the value is of a different type. + * + * @param key flag metadata key to retrieve + */ + public Boolean getBoolean(final String key) { + return getValue(key, Boolean.class); + } + + /** + * Generic value retrieval for the given key. + */ + public T getValue(final String key, final Class type) { + final Object o = metadata.get(key); + + if (o == null) { + log.debug("Metadata key " + key + "does not exist"); + return null; + } + + try { + return type.cast(o); + } catch (ClassCastException e) { + log.debug("Error retrieving value for key " + key, e); + return null; + } + } + + + /** + * Obtain a builder for {@link ImmutableMetadata}. + */ + public static ImmutableMetadataBuilder builder() { + return new ImmutableMetadataBuilder(); + } + + /** + * Immutable builder for {@link ImmutableMetadata}. + */ + public static class ImmutableMetadataBuilder { + private final Map metadata; + + private ImmutableMetadataBuilder() { + metadata = new HashMap<>(); + } + + /** + * Add String value to the metadata. + * + * @param key flag metadata key to add + * @param value flag metadata value to add + */ + public ImmutableMetadataBuilder addString(final String key, final String value) { + metadata.put(key, value); + return this; + } + + /** + * Add Integer value to the metadata. + * + * @param key flag metadata key to add + * @param value flag metadata value to add + */ + public ImmutableMetadataBuilder addInteger(final String key, final Integer value) { + metadata.put(key, value); + return this; + } + + /** + * Add Long value to the metadata. + * + * @param key flag metadata key to add + * @param value flag metadata value to add + */ + public ImmutableMetadataBuilder addLong(final String key, final Long value) { + metadata.put(key, value); + return this; + } + + /** + * Add Float value to the metadata. + * + * @param key flag metadata key to add + * @param value flag metadata value to add + */ + public ImmutableMetadataBuilder addFloat(final String key, final Float value) { + metadata.put(key, value); + return this; + } + + /** + * Add Double value to the metadata. + * + * @param key flag metadata key to add + * @param value flag metadata value to add + */ + public ImmutableMetadataBuilder addDouble(final String key, final Double value) { + metadata.put(key, value); + return this; + } + + /** + * Add Boolean value to the metadata. + * + * @param key flag metadata key to add + * @param value flag metadata value to add + */ + public ImmutableMetadataBuilder addBoolean(final String key, final Boolean value) { + metadata.put(key, value); + return this; + } + + /** + * Retrieve {@link ImmutableMetadata} with provided key,value pairs. + */ + public ImmutableMetadata build() { + return new ImmutableMetadata(this.metadata); + } + + } +} diff --git a/src/main/java/dev/openfeature/sdk/MutableContext.java b/src/main/java/dev/openfeature/sdk/MutableContext.java index 6abd74a5..9e7069ca 100644 --- a/src/main/java/dev/openfeature/sdk/MutableContext.java +++ b/src/main/java/dev/openfeature/sdk/MutableContext.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Map; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; import lombok.ToString; @@ -16,6 +17,7 @@ * be modified after instantiation. */ @ToString +@EqualsAndHashCode @SuppressWarnings("PMD.BeanMembersShouldSerialize") public class MutableContext implements EvaluationContext { @@ -88,7 +90,7 @@ public MutableContext add(String key, List value) { @Override public EvaluationContext merge(EvaluationContext overridingContext) { if (overridingContext == null) { - return new MutableContext(this.asMap()); + return new MutableContext(this.targetingKey, this.asMap()); } Map merged = this.merge(map -> new MutableStructure(map), diff --git a/src/main/java/dev/openfeature/sdk/NoOpProvider.java b/src/main/java/dev/openfeature/sdk/NoOpProvider.java index c2e841a5..d3d9ca21 100644 --- a/src/main/java/dev/openfeature/sdk/NoOpProvider.java +++ b/src/main/java/dev/openfeature/sdk/NoOpProvider.java @@ -10,6 +10,12 @@ public class NoOpProvider implements FeatureProvider { @Getter private final String name = "No-op Provider"; + // The Noop provider is ALWAYS NOT_READY, otherwise READY handlers would run immediately when attached. + @Override + public ProviderState getState() { + return ProviderState.NOT_READY; + } + @Override public Metadata getMetadata() { return new Metadata() { diff --git a/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java b/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java index e85f4e13..42ff4708 100644 --- a/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java +++ b/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java @@ -3,27 +3,31 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Set; +import java.util.function.Consumer; import javax.annotation.Nullable; import dev.openfeature.sdk.internal.AutoCloseableLock; import dev.openfeature.sdk.internal.AutoCloseableReentrantReadWriteLock; +import lombok.extern.slf4j.Slf4j; /** - * A global singleton which holds base configuration for the OpenFeature library. + * A global singleton which holds base configuration for the OpenFeature + * library. * Configuration here will be shared across all {@link Client}s. */ -public class OpenFeatureAPI { +@Slf4j +public class OpenFeatureAPI implements EventBus { // package-private multi-read/single-write lock - static AutoCloseableReentrantReadWriteLock hooksLock = new AutoCloseableReentrantReadWriteLock(); - static AutoCloseableReentrantReadWriteLock providerLock = new AutoCloseableReentrantReadWriteLock(); - static AutoCloseableReentrantReadWriteLock contextLock = new AutoCloseableReentrantReadWriteLock(); - private FeatureProvider provider; + static AutoCloseableReentrantReadWriteLock lock = new AutoCloseableReentrantReadWriteLock(); private EvaluationContext evaluationContext; - private List apiHooks; + private final List apiHooks; + private ProviderRepository providerRepository = new ProviderRepository(); + private EventSupport eventSupport = new EventSupport(); - private OpenFeatureAPI() { - this.apiHooks = new ArrayList<>(); + protected OpenFeatureAPI() { + apiHooks = new ArrayList<>(); } private static class SingletonHolder { @@ -32,6 +36,7 @@ private static class SingletonHolder { /** * Provisions the {@link OpenFeatureAPI} singleton (if needed) and returns it. + * * @return The singleton instance. */ public static OpenFeatureAPI getInstance() { @@ -39,26 +44,41 @@ public static OpenFeatureAPI getInstance() { } public Metadata getProviderMetadata() { - return provider.getMetadata(); + return getProvider().getMetadata(); } + public Metadata getProviderMetadata(String clientName) { + return getProvider(clientName).getMetadata(); + } + + /** + * {@inheritDoc} + */ public Client getClient() { return getClient(null, null); } + /** + * {@inheritDoc} + */ public Client getClient(@Nullable String name) { return getClient(name, null); } + /** + * {@inheritDoc} + */ public Client getClient(@Nullable String name, @Nullable String version) { - return new OpenFeatureClient(this, name, version); + return new OpenFeatureClient(this, + name, + version); } /** * {@inheritDoc} */ public void setEvaluationContext(EvaluationContext evaluationContext) { - try (AutoCloseableLock __ = contextLock.writeLockAutoCloseable()) { + try (AutoCloseableLock __ = lock.writeLockAutoCloseable()) { this.evaluationContext = evaluationContext; } } @@ -67,34 +87,87 @@ public void setEvaluationContext(EvaluationContext evaluationContext) { * {@inheritDoc} */ public EvaluationContext getEvaluationContext() { - try (AutoCloseableLock __ = contextLock.readLockAutoCloseable()) { + try (AutoCloseableLock __ = lock.readLockAutoCloseable()) { return this.evaluationContext; } } /** - * {@inheritDoc} + * Set the default provider. */ public void setProvider(FeatureProvider provider) { - try (AutoCloseableLock __ = providerLock.writeLockAutoCloseable()) { - this.provider = provider; + try (AutoCloseableLock __ = lock.writeLockAutoCloseable()) { + providerRepository.setProvider( + provider, + (p) -> attachEventProvider(p), + (p) -> emitReady(p), + (p) -> detachEventProvider(p), + (p, message) -> emitError(p, message)); } } /** - * {@inheritDoc} + * Add a provider for a named client. + * + * @param clientName The name of the client. + * @param provider The provider to set. */ - public FeatureProvider getProvider() { - try (AutoCloseableLock __ = providerLock.readLockAutoCloseable()) { - return this.provider; + public void setProvider(String clientName, FeatureProvider provider) { + try (AutoCloseableLock __ = lock.writeLockAutoCloseable()) { + providerRepository.setProvider(clientName, + provider, + this::attachEventProvider, + this::emitReady, + this::detachEventProvider, + this::emitError); + } + } + + private void attachEventProvider(FeatureProvider provider) { + if (provider instanceof EventProvider) { + ((EventProvider)provider).attach((p, event, details) -> { + runHandlersForProvider(p, event, details); + }); } } + private void emitReady(FeatureProvider provider) { + runHandlersForProvider(provider, ProviderEvent.PROVIDER_READY, ProviderEventDetails.builder().build()); + } + + private void detachEventProvider(FeatureProvider provider) { + if (provider instanceof EventProvider) { + ((EventProvider)provider).detach(); + } + } + + private void emitError(FeatureProvider provider, String message) { + runHandlersForProvider(provider, ProviderEvent.PROVIDER_ERROR, + ProviderEventDetails.builder().message(message).build()); + } + + /** + * Return the default provider. + */ + public FeatureProvider getProvider() { + return providerRepository.getProvider(); + } + + /** + * Fetch a provider for a named client. If not found, return the default. + * + * @param name The client name to look for. + * @return A named {@link FeatureProvider} + */ + public FeatureProvider getProvider(String name) { + return providerRepository.getProvider(name); + } + /** * {@inheritDoc} */ public void addHooks(Hook... hooks) { - try (AutoCloseableLock __ = hooksLock.writeLockAutoCloseable()) { + try (AutoCloseableLock __ = lock.writeLockAutoCloseable()) { this.apiHooks.addAll(Arrays.asList(hooks)); } } @@ -103,7 +176,7 @@ public void addHooks(Hook... hooks) { * {@inheritDoc} */ public List getHooks() { - try (AutoCloseableLock __ = hooksLock.readLockAutoCloseable()) { + try (AutoCloseableLock __ = lock.readLockAutoCloseable()) { return this.apiHooks; } } @@ -112,8 +185,124 @@ public List getHooks() { * {@inheritDoc} */ public void clearHooks() { - try (AutoCloseableLock __ = hooksLock.writeLockAutoCloseable()) { + try (AutoCloseableLock __ = lock.writeLockAutoCloseable()) { this.apiHooks.clear(); } } + + public void shutdown() { + providerRepository.shutdown(); + eventSupport.shutdown(); + } + + /** + * {@inheritDoc} + */ + @Override + public OpenFeatureAPI onProviderReady(Consumer handler) { + return this.on(ProviderEvent.PROVIDER_READY, handler); + } + + /** + * {@inheritDoc} + */ + @Override + public OpenFeatureAPI onProviderConfigurationChanged(Consumer handler) { + return this.on(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, handler); + } + + /** + * {@inheritDoc} + */ + @Override + public OpenFeatureAPI onProviderStale(Consumer handler) { + return this.on(ProviderEvent.PROVIDER_STALE, handler); + } + + /** + * {@inheritDoc} + */ + @Override + public OpenFeatureAPI onProviderError(Consumer handler) { + return this.on(ProviderEvent.PROVIDER_ERROR, handler); + } + + /** + * {@inheritDoc} + */ + @Override + public OpenFeatureAPI on(ProviderEvent event, Consumer handler) { + try (AutoCloseableLock __ = lock.writeLockAutoCloseable()) { + this.eventSupport.addGlobalHandler(event, handler); + return this; + } + } + + /** + * {@inheritDoc} + */ + @Override + public OpenFeatureAPI removeHandler(ProviderEvent event, Consumer handler) { + this.eventSupport.removeGlobalHandler(event, handler); + return this; + } + + void removeHandler(String clientName, ProviderEvent event, Consumer handler) { + try (AutoCloseableLock __ = lock.writeLockAutoCloseable()) { + eventSupport.removeClientHandler(clientName, event, handler); + } + } + + void addHandler(String clientName, ProviderEvent event, Consumer handler) { + try (AutoCloseableLock __ = lock.writeLockAutoCloseable()) { + // if the provider is READY, run immediately + if (ProviderEvent.PROVIDER_READY.equals(event) + && ProviderState.READY.equals(this.providerRepository.getProvider(clientName).getState())) { + eventSupport.runHandler(handler, EventDetails.builder().clientName(clientName).build()); + } + eventSupport.addClientHandler(clientName, event, handler); + } + } + + /** + * This method is only here for testing as otherwise all tests after the API + * shutdown test would fail. + */ + final void reset() { + providerRepository = new ProviderRepository(); + eventSupport = new EventSupport(); + } + + /** + * Runs the handlers associated with a particular provider. + * + * @param provider the provider from where this event originated + * @param event the event type + * @param details the event details + */ + private void runHandlersForProvider(FeatureProvider provider, ProviderEvent event, ProviderEventDetails details) { + try (AutoCloseableLock __ = lock.readLockAutoCloseable()) { + + List clientNamesForProvider = providerRepository + .getClientNamesForProvider(provider); + + // run the global handlers + eventSupport.runGlobalHandlers(event, EventDetails.fromProviderEventDetails(details)); + + // run the handlers associated with named clients for this provider + clientNamesForProvider.forEach(name -> { + eventSupport.runClientHandlers(name, event, EventDetails.fromProviderEventDetails(details, name)); + }); + + if (providerRepository.isDefaultProvider(provider)) { + // run handlers for clients that have no bound providers (since this is the default) + Set allClientNames = eventSupport.getAllClientNames(); + Set boundClientNames = providerRepository.getAllBoundClientNames(); + allClientNames.removeAll(boundClientNames); + allClientNames.forEach(name -> { + eventSupport.runClientHandlers(name, event, EventDetails.fromProviderEventDetails(details, name)); + }); + } + } + } } diff --git a/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java b/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java index a13eb942..05d79d02 100644 --- a/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java +++ b/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java @@ -5,6 +5,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import dev.openfeature.sdk.exceptions.GeneralError; import dev.openfeature.sdk.exceptions.OpenFeatureError; @@ -33,13 +34,16 @@ public class OpenFeatureClient implements Client { private EvaluationContext evaluationContext; /** - * Client for evaluating the flag. There may be multiples of these floating - * around. + * Deprecated public constructor. Use OpenFeature.API.getClient() instead. * * @param openFeatureAPI Backing global singleton * @param name Name of the client (used by observability tools). * @param version Version of the client (used by observability tools). + * @deprecated Do not use this constructor. It's for internal use only. + * Clients created using it will not run event handlers. + * Use the OpenFeatureAPI's getClient factory method instead. */ + @Deprecated() // TODO: eventually we will make this non-public public OpenFeatureClient(OpenFeatureAPI openFeatureAPI, String name, String version) { this.openfeatureApi = openFeatureAPI; this.name = name; @@ -95,21 +99,17 @@ private FlagEvaluationDetails evaluateFlag(FlagValueType type, String key Map hints = Collections.unmodifiableMap(flagOptions.getHookHints()); ctx = ObjectUtils.defaultIfNull(ctx, () -> new ImmutableContext()); - FlagEvaluationDetails details = null; List mergedHooks = null; HookContext hookCtx = null; - FeatureProvider provider = null; + FeatureProvider provider; try { final EvaluationContext apiContext; final EvaluationContext clientContext; // openfeatureApi.getProvider() must be called once to maintain a consistent reference - provider = ObjectUtils.defaultIfNull(openfeatureApi.getProvider(), () -> { - log.debug("No provider configured, using no-op provider."); - return new NoOpProvider(); - }); + provider = openfeatureApi.getProvider(this.name); mergedHooks = ObjectUtils.merge(provider.getProviderHooks(), flagOptions.getHooks(), clientHooks, openfeatureApi.getHooks()); @@ -344,4 +344,54 @@ public FlagEvaluationDetails getObjectDetails(String key, Value defaultVa public Metadata getMetadata() { return () -> name; } + + /** + * {@inheritDoc} + */ + @Override + public Client onProviderReady(Consumer handler) { + return on(ProviderEvent.PROVIDER_READY, handler); + } + + /** + * {@inheritDoc} + */ + @Override + public Client onProviderConfigurationChanged(Consumer handler) { + return on(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, handler); + } + + /** + * {@inheritDoc} + */ + @Override + public Client onProviderError(Consumer handler) { + return on(ProviderEvent.PROVIDER_ERROR, handler); + } + + /** + * {@inheritDoc} + */ + @Override + public Client onProviderStale(Consumer handler) { + return on(ProviderEvent.PROVIDER_STALE, handler); + } + + /** + * {@inheritDoc} + */ + @Override + public Client on(ProviderEvent event, Consumer handler) { + OpenFeatureAPI.getInstance().addHandler(name, event, handler); + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public Client removeHandler(ProviderEvent event, Consumer handler) { + OpenFeatureAPI.getInstance().removeHandler(name, event, handler); + return this; + } } diff --git a/src/main/java/dev/openfeature/sdk/ProviderEvaluation.java b/src/main/java/dev/openfeature/sdk/ProviderEvaluation.java index 9ba1ab9a..c4720263 100644 --- a/src/main/java/dev/openfeature/sdk/ProviderEvaluation.java +++ b/src/main/java/dev/openfeature/sdk/ProviderEvaluation.java @@ -1,16 +1,27 @@ package dev.openfeature.sdk; +import javax.annotation.Nullable; + +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; -import javax.annotation.Nullable; - -@SuppressWarnings("checkstyle:MissingJavadocType") -@Data @Builder +/** + * Contains information about how the a flag was evaluated, including the resolved value. + * + * @param the type of the flag being evaluated. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor public class ProviderEvaluation implements BaseEvaluation { T value; @Nullable String variant; @Nullable private String reason; ErrorCode errorCode; @Nullable private String errorMessage; + @Builder.Default + private ImmutableMetadata flagMetadata = ImmutableMetadata.builder().build(); } diff --git a/src/main/java/dev/openfeature/sdk/ProviderEvent.java b/src/main/java/dev/openfeature/sdk/ProviderEvent.java new file mode 100644 index 00000000..dcefd606 --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/ProviderEvent.java @@ -0,0 +1,8 @@ +package dev.openfeature.sdk; + +/** + * Provider event types. + */ +public enum ProviderEvent { + PROVIDER_READY, PROVIDER_CONFIGURATION_CHANGED, PROVIDER_ERROR, PROVIDER_STALE; +} diff --git a/src/main/java/dev/openfeature/sdk/ProviderEventDetails.java b/src/main/java/dev/openfeature/sdk/ProviderEventDetails.java new file mode 100644 index 00000000..149c92a7 --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/ProviderEventDetails.java @@ -0,0 +1,18 @@ +package dev.openfeature.sdk; + +import java.util.List; + +import javax.annotation.Nullable; + +import lombok.Data; +import lombok.experimental.SuperBuilder; + +/** + * The details of a particular event. + */ +@Data @SuperBuilder(toBuilder = true) +public class ProviderEventDetails { + @Nullable private List flagsChanged; + @Nullable private String message; + @Nullable private ImmutableMetadata eventMetadata; +} diff --git a/src/main/java/dev/openfeature/sdk/ProviderRepository.java b/src/main/java/dev/openfeature/sdk/ProviderRepository.java new file mode 100644 index 00000000..0ff3b70b --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/ProviderRepository.java @@ -0,0 +1,164 @@ +package dev.openfeature.sdk; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.annotation.Nullable; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +class ProviderRepository { + + private final Map providers = new ConcurrentHashMap<>(); + private final AtomicReference defaultProvider = new AtomicReference<>(new NoOpProvider()); + private final ExecutorService taskExecutor = Executors.newCachedThreadPool(); + + /** + * Return the default provider. + */ + public FeatureProvider getProvider() { + return defaultProvider.get(); + } + + /** + * Fetch a provider for a named client. If not found, return the default. + * + * @param name The client name to look for. + * @return A named {@link FeatureProvider} + */ + public FeatureProvider getProvider(String name) { + return Optional.ofNullable(name).map(this.providers::get).orElse(this.defaultProvider.get()); + } + + public List getClientNamesForProvider(FeatureProvider provider) { + return providers.entrySet().stream() + .filter(entry -> entry.getValue().equals(provider)) + .map(entry -> entry.getKey()).collect(Collectors.toList()); + } + + public Set getAllBoundClientNames() { + return providers.keySet(); + } + + public boolean isDefaultProvider(FeatureProvider provider) { + return this.getProvider().equals(provider); + } + + /** + * Set the default provider. + */ + public void setProvider(FeatureProvider provider, + Consumer afterSet, + Consumer afterInit, + Consumer afterShutdown, + BiConsumer afterError) { + if (provider == null) { + throw new IllegalArgumentException("Provider cannot be null"); + } + initializeProvider(null, provider, afterSet, afterInit, afterShutdown, afterError); + } + + /** + * Add a provider for a named client. + * + * @param clientName The name of the client. + * @param provider The provider to set. + */ + public void setProvider(String clientName, + FeatureProvider provider, + Consumer afterSet, + Consumer afterInit, + Consumer afterShutdown, + BiConsumer afterError) { + if (provider == null) { + throw new IllegalArgumentException("Provider cannot be null"); + } + if (clientName == null) { + throw new IllegalArgumentException("clientName cannot be null"); + } + initializeProvider(clientName, provider, afterSet, afterInit, afterShutdown, afterError); + } + + private void initializeProvider(@Nullable String clientName, + FeatureProvider newProvider, + Consumer afterSet, + Consumer afterInit, + Consumer afterShutdown, + BiConsumer afterError) { + // provider is set immediately, on this thread + FeatureProvider oldProvider = clientName != null + ? this.providers.put(clientName, newProvider) + : this.defaultProvider.getAndSet(newProvider); + afterSet.accept(newProvider); + taskExecutor.submit(() -> { + // initialization happens in a different thread + try { + if (ProviderState.NOT_READY.equals(newProvider.getState())) { + newProvider.initialize(OpenFeatureAPI.getInstance().getEvaluationContext()); + afterInit.accept(newProvider); + } + shutDownOld(oldProvider, afterShutdown); + } catch (Exception e) { + log.error("Exception when initializing feature provider {}", newProvider.getClass().getName(), e); + afterError.accept(newProvider, e.getMessage()); + } + }); + } + + private void shutDownOld(FeatureProvider oldProvider,Consumer afterShutdown) { + if (!isProviderRegistered(oldProvider)) { + shutdownProvider(oldProvider); + afterShutdown.accept(oldProvider); + } + } + + private boolean isProviderRegistered(FeatureProvider oldProvider) { + return this.providers.containsValue(oldProvider) || this.defaultProvider.get().equals(oldProvider); + } + + private void shutdownProvider(FeatureProvider provider) { + taskExecutor.submit(() -> { + try { + // detachProviderEvents(provider); + provider.shutdown(); + } catch (Exception e) { + log.error("Exception when shutting down feature provider {}", provider.getClass().getName(), e); + } + }); + } + + /** + * Shuts down this repository which includes shutting down all FeatureProviders + * that are registered, + * including the default feature provider. + */ + public void shutdown() { + Stream + .concat(Stream.of(this.defaultProvider.get()), this.providers.values().stream()) + .distinct() + .forEach(this::shutdownProvider); + setProvider(new NoOpProvider(), + (FeatureProvider fp) -> { + }, + (FeatureProvider fp) -> { + }, + (FeatureProvider fp) -> { + }, + (FeatureProvider fp, + String message) -> { + }); + this.providers.clear(); + taskExecutor.shutdown(); + } +} diff --git a/src/main/java/dev/openfeature/sdk/ProviderState.java b/src/main/java/dev/openfeature/sdk/ProviderState.java new file mode 100644 index 00000000..6685f8fe --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/ProviderState.java @@ -0,0 +1,8 @@ +package dev.openfeature.sdk; + +/** + * Indicates the state of the provider. + */ +public enum ProviderState { + READY, NOT_READY, ERROR; +} diff --git a/src/main/java/dev/openfeature/sdk/internal/ObjectUtils.java b/src/main/java/dev/openfeature/sdk/internal/ObjectUtils.java index ff16422e..34caadae 100644 --- a/src/main/java/dev/openfeature/sdk/internal/ObjectUtils.java +++ b/src/main/java/dev/openfeature/sdk/internal/ObjectUtils.java @@ -69,5 +69,4 @@ public static List merge(List... sources) { .flatMap(Collection::stream) .collect(Collectors.toList()); } - } diff --git a/src/main/java/dev/openfeature/sdk/internal/TriConsumer.java b/src/main/java/dev/openfeature/sdk/internal/TriConsumer.java new file mode 100644 index 00000000..723f4aeb --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/internal/TriConsumer.java @@ -0,0 +1,38 @@ +package dev.openfeature.sdk.internal; + +import java.util.Objects; + +/** + * Like {@link java.util.function.BiConsumer} but with 3 params. + * + * @see java.util.function.BiConsumer + */ +@FunctionalInterface +public interface TriConsumer { + + /** + * Performs this operation on the given arguments. + * + * @param t the first input argument + * @param u the second input argument + * @param v the third input argument + */ + void accept(T t, U u, V v); + + /** + * Returns a composed {@code TriConsumer} that performs an additional operation. + * + * @param after the operation to perform after this operation + * @return a composed {@code TriConsumer} that performs in sequence this + * operation followed by the {@code after} operation + * @throws NullPointerException if {@code after} is null + */ + default TriConsumer andThen(TriConsumer after) { + Objects.requireNonNull(after); + + return (t, u, v) -> { + accept(t, u, v); + after.accept(t, u, v); + }; + } +} \ No newline at end of file diff --git a/src/test/java/dev/openfeature/sdk/ClientProviderMappingTest.java b/src/test/java/dev/openfeature/sdk/ClientProviderMappingTest.java new file mode 100644 index 00000000..8f022a38 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/ClientProviderMappingTest.java @@ -0,0 +1,23 @@ +package dev.openfeature.sdk; + +import dev.openfeature.sdk.testutils.FeatureProviderTestUtils; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ClientProviderMappingTest { + + @Test + void clientProviderTest() { + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + + FeatureProviderTestUtils.setFeatureProvider("client1", new DoSomethingProvider()); + FeatureProviderTestUtils.setFeatureProvider("client2", new NoOpProvider()); + + Client c1 = api.getClient("client1"); + Client c2 = api.getClient("client2"); + + assertTrue(c1.getBooleanValue("test", false)); + assertFalse(c2.getBooleanValue("test", false)); + } +} diff --git a/src/test/java/dev/openfeature/sdk/DeveloperExperienceTest.java b/src/test/java/dev/openfeature/sdk/DeveloperExperienceTest.java index f32711a2..b5e5bedf 100644 --- a/src/test/java/dev/openfeature/sdk/DeveloperExperienceTest.java +++ b/src/test/java/dev/openfeature/sdk/DeveloperExperienceTest.java @@ -12,6 +12,7 @@ import java.util.Map; import java.util.Optional; +import dev.openfeature.sdk.testutils.FeatureProviderTestUtils; import org.junit.jupiter.api.Test; import dev.openfeature.sdk.fixtures.HookFixtures; @@ -19,15 +20,6 @@ class DeveloperExperienceTest implements HookFixtures { transient String flagKey = "mykey"; - @Test void noProviderSet() { - final String noOp = "no-op"; - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(null); - Client client = api.getClient(); - String retval = client.getStringValue(flagKey, noOp); - assertEquals(noOp, retval); - } - @Test void simpleBooleanFlag() { OpenFeatureAPI api = OpenFeatureAPI.getInstance(); api.setProvider(new NoOpProvider()); @@ -86,7 +78,7 @@ class DeveloperExperienceTest implements HookFixtures { @Test void brokenProvider() { OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(new AlwaysBrokenProvider()); + FeatureProviderTestUtils.setFeatureProvider(new AlwaysBrokenProvider()); Client client = api.getClient(); FlagEvaluationDetails retval = client.getBooleanDetails(flagKey, false); assertEquals(ErrorCode.FLAG_NOT_FOUND, retval.getErrorCode()); @@ -96,14 +88,14 @@ class DeveloperExperienceTest implements HookFixtures { } @Test - void providerLockedPerTransaction() throws InterruptedException { + void providerLockedPerTransaction() { class MutatingHook implements Hook { @Override // change the provider during a before hook - this should not impact the evaluation in progress public Optional before(HookContext ctx, Map hints) { - OpenFeatureAPI.getInstance().setProvider(new NoOpProvider()); + FeatureProviderTestUtils.setFeatureProvider(new NoOpProvider()); return Optional.empty(); } } @@ -111,7 +103,7 @@ public Optional before(HookContext ctx, Map hints) { final String defaultValue = "string-value"; final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); final Client client = api.getClient(); - api.setProvider(new DoSomethingProvider()); + FeatureProviderTestUtils.setFeatureProvider(new DoSomethingProvider()); api.addHooks(new MutatingHook()); // if provider is changed during an evaluation transaction it should proceed with the original provider diff --git a/src/test/java/dev/openfeature/sdk/DoSomethingProvider.java b/src/test/java/dev/openfeature/sdk/DoSomethingProvider.java index d87fa374..8d1c4514 100644 --- a/src/test/java/dev/openfeature/sdk/DoSomethingProvider.java +++ b/src/test/java/dev/openfeature/sdk/DoSomethingProvider.java @@ -1,11 +1,14 @@ package dev.openfeature.sdk; -public class DoSomethingProvider implements FeatureProvider { +class DoSomethingProvider implements FeatureProvider { + + static final String name = "Something"; + // Flag evaluation metadata + static final ImmutableMetadata flagMetadata = ImmutableMetadata.builder().build(); - public static final String name = "Something"; private EvaluationContext savedContext; - public EvaluationContext getMergedContext() { + EvaluationContext getMergedContext() { return savedContext; } @@ -18,13 +21,16 @@ public Metadata getMetadata() { public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { savedContext = ctx; return ProviderEvaluation.builder() - .value(!defaultValue).build(); + .value(!defaultValue) + .flagMetadata(flagMetadata) + .build(); } @Override public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { return ProviderEvaluation.builder() .value(new StringBuilder(defaultValue).reverse().toString()) + .flagMetadata(flagMetadata) .build(); } @@ -33,6 +39,7 @@ public ProviderEvaluation getIntegerEvaluation(String key, Integer defa savedContext = ctx; return ProviderEvaluation.builder() .value(defaultValue * 100) + .flagMetadata(flagMetadata) .build(); } @@ -41,6 +48,7 @@ public ProviderEvaluation getDoubleEvaluation(String key, Double default savedContext = ctx; return ProviderEvaluation.builder() .value(defaultValue * 100) + .flagMetadata(flagMetadata) .build(); } @@ -49,6 +57,7 @@ public ProviderEvaluation getObjectEvaluation(String key, Value defaultVa savedContext = invocationContext; return ProviderEvaluation.builder() .value(null) + .flagMetadata(flagMetadata) .build(); } } diff --git a/src/test/java/dev/openfeature/sdk/EvalContextTest.java b/src/test/java/dev/openfeature/sdk/EvalContextTest.java index 29fd0898..f4cd804c 100644 --- a/src/test/java/dev/openfeature/sdk/EvalContextTest.java +++ b/src/test/java/dev/openfeature/sdk/EvalContextTest.java @@ -162,6 +162,13 @@ public class EvalContextTest { assertEquals(key1, ctxMerged.getTargetingKey()); } + @Test void merge_null_returns_value() { + MutableContext ctx1 = new MutableContext("key"); + ctx1.add("mything", "value"); + EvaluationContext result = ctx1.merge(null); + assertEquals(ctx1, result); + } + @Test void merge_targeting_key() { String key1 = "key1"; MutableContext ctx1 = new MutableContext(key1); diff --git a/src/test/java/dev/openfeature/sdk/EventProviderTest.java b/src/test/java/dev/openfeature/sdk/EventProviderTest.java new file mode 100644 index 00000000..cb73b529 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/EventProviderTest.java @@ -0,0 +1,130 @@ +package dev.openfeature.sdk; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import dev.openfeature.sdk.internal.TriConsumer; + +class EventProviderTest { + + @Test + @DisplayName("should run attached onEmit with emitters") + void emitsEventsWhenAttached() { + TestEventProvider eventProvider = new TestEventProvider(); + TriConsumer onEmit = mockOnEmit(); + eventProvider.attach(onEmit); + + ProviderEventDetails details = ProviderEventDetails.builder().build(); + eventProvider.emit(ProviderEvent.PROVIDER_READY, details); + eventProvider.emitProviderReady(details); + eventProvider.emitProviderConfigurationChanged(details); + eventProvider.emitProviderStale(details); + eventProvider.emitProviderError(details); + + verify(onEmit, times(2)).accept(eventProvider, ProviderEvent.PROVIDER_READY, details); + verify(onEmit, times(1)).accept(eventProvider, ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, details); + verify(onEmit, times(1)).accept(eventProvider, ProviderEvent.PROVIDER_STALE, details); + verify(onEmit, times(1)).accept(eventProvider, ProviderEvent.PROVIDER_ERROR, details); + } + + @Test + @DisplayName("should do nothing with emitters if no onEmit attached") + void doesNotEmitsEventsWhenNotAttached() { + TestEventProvider eventProvider = new TestEventProvider(); + + // don't attach this emitter + TriConsumer onEmit = mockOnEmit(); + + ProviderEventDetails details = ProviderEventDetails.builder().build(); + eventProvider.emit(ProviderEvent.PROVIDER_READY, details); + eventProvider.emitProviderReady(details); + eventProvider.emitProviderConfigurationChanged(details); + eventProvider.emitProviderStale(details); + eventProvider.emitProviderError(details); + + // should not be called + verify(onEmit, never()).accept(any(), any(), any()); + } + + @Test + @DisplayName("should throw if second different onEmit attached") + void throwsWhenOnEmitDifferent() { + TestEventProvider eventProvider = new TestEventProvider(); + TriConsumer onEmit1 = mockOnEmit(); + TriConsumer onEmit2 = mockOnEmit(); + eventProvider.attach(onEmit1); + assertThrows(IllegalStateException.class, () -> eventProvider.attach(onEmit2)); + } + + + @Test + @DisplayName("should not throw if second same onEmit attached") + void doesNotThrowWhenOnEmitSame() { + TestEventProvider eventProvider = new TestEventProvider(); + TriConsumer onEmit1 = mockOnEmit(); + TriConsumer onEmit2 = onEmit1; + eventProvider.attach(onEmit1); + eventProvider.attach(onEmit2); // should not throw, same instance. noop + } + + + class TestEventProvider extends EventProvider { + + @Override + public Metadata getMetadata() { + return new Metadata() { + @Override + public String getName() { + return "TestEventProvider"; + } + }; + } + + @Override + public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, + EvaluationContext ctx) { + // TODO Auto-generated method stub + throw new UnsupportedOperationException("Unimplemented method 'getBooleanEvaluation'"); + } + + @Override + public ProviderEvaluation getStringEvaluation(String key, String defaultValue, + EvaluationContext ctx) { + // TODO Auto-generated method stub + throw new UnsupportedOperationException("Unimplemented method 'getStringEvaluation'"); + } + + @Override + public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, + EvaluationContext ctx) { + // TODO Auto-generated method stub + throw new UnsupportedOperationException("Unimplemented method 'getIntegerEvaluation'"); + } + + @Override + public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, + EvaluationContext ctx) { + // TODO Auto-generated method stub + throw new UnsupportedOperationException("Unimplemented method 'getDoubleEvaluation'"); + } + + @Override + public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, + EvaluationContext ctx) { + // TODO Auto-generated method stub + throw new UnsupportedOperationException("Unimplemented method 'getObjectEvaluation'"); + } + } + + @SuppressWarnings("unchecked") + private TriConsumer mockOnEmit() { + return (TriConsumer)mock(TriConsumer.class); + } +} \ No newline at end of file diff --git a/src/test/java/dev/openfeature/sdk/EventsTest.java b/src/test/java/dev/openfeature/sdk/EventsTest.java new file mode 100644 index 00000000..70f81657 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/EventsTest.java @@ -0,0 +1,599 @@ +package dev.openfeature.sdk; + +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.after; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; + +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; + +import dev.openfeature.sdk.testutils.TestEventsProvider; +import io.cucumber.java.AfterAll; + +class EventsTest { + + private static final int TIMEOUT = 200; + private static final int INIT_DELAY = TIMEOUT / 2; + + @AfterAll + public static void resetDefaultProvider() { + OpenFeatureAPI.getInstance().setProvider(new NoOpProvider()); + } + + @Nested + class ApiEvents { + + @Nested + @DisplayName("named provider") + class NamedProvider { + + @Nested + @DisplayName("initialization") + class Initialization { + + @Test + @DisplayName("should fire initial READY event when provider init succeeds") + @Specification(number = "5.3.1", text = "If the provider's initialize function terminates normally," + + " PROVIDER_READY handlers MUST run.") + void apiInitReady() { + final Consumer handler = (Consumer)mockHandler(); + final String name = "apiInitReady"; + + TestEventsProvider provider = new TestEventsProvider(INIT_DELAY); + OpenFeatureAPI.getInstance().onProviderReady(handler); + OpenFeatureAPI.getInstance().setProvider(name, provider); + verify(handler, timeout(TIMEOUT).atLeastOnce()) + .accept(any()); + } + + @Test + @DisplayName("should fire initial ERROR event when provider init errors") + @Specification(number = "5.3.2", text = "If the provider's initialize function terminates abnormally," + + " PROVIDER_ERROR handlers MUST run.") + void apiInitError() { + final Consumer handler = mockHandler(); + final String name = "apiInitError"; + final String errMessage = "oh no!"; + + TestEventsProvider provider = new TestEventsProvider(INIT_DELAY, true, errMessage); + OpenFeatureAPI.getInstance().onProviderError(handler); + OpenFeatureAPI.getInstance().setProvider(name, provider); + verify(handler, timeout(TIMEOUT)).accept(argThat(details -> { + return errMessage.equals(details.getMessage()); + })); + } + } + + @Nested + @DisplayName("provider events") + class ProviderEvents { + + @Test + @DisplayName("should propagate events") + @Specification(number = "5.1.2", text = "When a provider signals the occurrence of a particular event, " + + + "the associated client and API event handlers MUST run.") + void apiShouldPropagateEvents() { + final Consumer handler = mockHandler(); + final String name = "apiShouldPropagateEvents"; + + TestEventsProvider provider = new TestEventsProvider(INIT_DELAY); + OpenFeatureAPI.getInstance().setProvider(name, provider); + OpenFeatureAPI.getInstance().onProviderConfigurationChanged(handler); + + provider.mockEvent(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, EventDetails.builder().build()); + verify(handler, timeout(TIMEOUT)).accept(any()); + } + + @Test + @DisplayName("should support all event types") + @Specification(number = "5.1.1", text = "The provider MAY define a mechanism for signaling the occurrence " + + "of one of a set of events, including PROVIDER_READY, PROVIDER_ERROR, " + + "PROVIDER_CONFIGURATION_CHANGED and PROVIDER_STALE, with a provider event details payload.") + @Specification(number = "5.2.2", text = "The API MUST provide a function for associating handler functions" + + + " with a particular provider event type.") + void apiShouldSupportAllEventTypes() throws Exception { + final String name = "apiShouldSupportAllEventTypes"; + final Consumer handler1 = mockHandler(); + final Consumer handler2 = mockHandler(); + final Consumer handler3 = mockHandler(); + final Consumer handler4 = mockHandler(); + + TestEventsProvider provider = new TestEventsProvider(INIT_DELAY); + OpenFeatureAPI.getInstance().setProvider(name, provider); + + OpenFeatureAPI.getInstance().onProviderReady(handler1); + OpenFeatureAPI.getInstance().onProviderConfigurationChanged(handler2); + OpenFeatureAPI.getInstance().onProviderStale(handler3); + OpenFeatureAPI.getInstance().onProviderError(handler4); + + Arrays.asList(ProviderEvent.values()).stream().forEach(eventType -> { + provider.mockEvent(eventType, ProviderEventDetails.builder().build()); + }); + + verify(handler1, timeout(TIMEOUT).atLeastOnce()).accept(any()); + verify(handler2, timeout(TIMEOUT).atLeastOnce()).accept(any()); + verify(handler3, timeout(TIMEOUT).atLeastOnce()).accept(any()); + verify(handler4, timeout(TIMEOUT).atLeastOnce()).accept(any()); + } + } + } + } + + @Nested + @DisplayName("client events") + class ClientEvents { + + @Nested + @DisplayName("default provider") + class DefaultProvider { + + @Nested + @DisplayName("provider events") + class ProviderEvents { + + @Test + @DisplayName("should propagate events for default provider and anonymous client") + @Specification(number = "5.1.2", text = "When a provider signals the occurrence of a particular event, the associated client and API event handlers MUST run.") + void shouldPropagateDefaultAndAnon() { + final Consumer handler = mockHandler(); + + TestEventsProvider provider = new TestEventsProvider(INIT_DELAY); + // set provider before getting a client + OpenFeatureAPI.getInstance().setProvider(provider); + Client client = OpenFeatureAPI.getInstance().getClient(); + client.onProviderStale(handler); + + provider.mockEvent(ProviderEvent.PROVIDER_STALE, EventDetails.builder().build()); + verify(handler, timeout(TIMEOUT)).accept(any()); + } + + @Test + @DisplayName("should propagate events for default provider and named client") + @Specification(number = "5.1.2", text = "When a provider signals the occurrence of a particular event, the associated client and API event handlers MUST run.") + void shouldPropagateDefaultAndNamed() { + final Consumer handler = mockHandler(); + final String name = "shouldPropagateDefaultAndNamed"; + + TestEventsProvider provider = new TestEventsProvider(INIT_DELAY); + // set provider before getting a client + OpenFeatureAPI.getInstance().setProvider(provider); + Client client = OpenFeatureAPI.getInstance().getClient(name); + client.onProviderStale(handler); + + provider.mockEvent(ProviderEvent.PROVIDER_STALE, EventDetails.builder().build()); + verify(handler, timeout(TIMEOUT)).accept(any()); + } + } + } + } + + @Nested + @DisplayName("named provider") + class NamedProvider { + + @Nested + @DisplayName("initialization") + class Initialization { + @Test + @DisplayName("should fire initial READY event when provider init succeeds after client retrieved") + @Specification(number = "5.3.1", text = "If the provider's initialize function terminates normally, PROVIDER_READY handlers MUST run.") + void initReadyProviderBefore() throws InterruptedException { + final Consumer handler = mockHandler(); + final String name = "initReadyProviderBefore"; + + TestEventsProvider provider = new TestEventsProvider(INIT_DELAY); + Client client = OpenFeatureAPI.getInstance().getClient(name); + client.onProviderReady(handler); + // set provider after getting a client + OpenFeatureAPI.getInstance().setProvider(name, provider); + verify(handler, timeout(TIMEOUT).atLeastOnce()) + .accept(argThat(details -> details.getClientName().equals(name))); + } + + @Test + @DisplayName("should fire initial READY event when provider init succeeds before client retrieved") + @Specification(number = "5.3.1", text = "If the provider's initialize function terminates normally, PROVIDER_READY handlers MUST run.") + void initReadyProviderAfter() { + final Consumer handler = mockHandler(); + final String name = "initReadyProviderAfter"; + + TestEventsProvider provider = new TestEventsProvider(INIT_DELAY); + // set provider before getting a client + OpenFeatureAPI.getInstance().setProvider(name, provider); + Client client = OpenFeatureAPI.getInstance().getClient(name); + client.onProviderReady(handler); + verify(handler, timeout(TIMEOUT).atLeastOnce()) + .accept(argThat(details -> details.getClientName().equals(name))); + } + + @Test + @DisplayName("should fire initial ERROR event when provider init errors after client retrieved") + @Specification(number = "5.3.2", text = "If the provider's initialize function terminates abnormally, PROVIDER_ERROR handlers MUST run.") + void initErrorProviderAfter() { + final Consumer handler = mockHandler(); + final String name = "initErrorProviderAfter"; + final String errMessage = "oh no!"; + + TestEventsProvider provider = new TestEventsProvider(INIT_DELAY, true, errMessage); + Client client = OpenFeatureAPI.getInstance().getClient(name); + client.onProviderError(handler); + // set provider after getting a client + OpenFeatureAPI.getInstance().setProvider(name, provider); + verify(handler, timeout(TIMEOUT)).accept(argThat(details -> { + return name.equals(details.getClientName()) + && errMessage.equals(details.getMessage()); + })); + } + + @Test + @DisplayName("should fire initial ERROR event when provider init errors before client retrieved") + @Specification(number = "5.3.2", text = "If the provider's initialize function terminates abnormally, PROVIDER_ERROR handlers MUST run.") + void initErrorProviderBefore() { + final Consumer handler = mockHandler(); + final String name = "initErrorProviderBefore"; + final String errMessage = "oh no!"; + + TestEventsProvider provider = new TestEventsProvider(INIT_DELAY, true, errMessage); + // set provider after getting a client + OpenFeatureAPI.getInstance().setProvider(name, provider); + Client client = OpenFeatureAPI.getInstance().getClient(name); + client.onProviderError(handler); + verify(handler, timeout(TIMEOUT)).accept(argThat(details -> { + return name.equals(details.getClientName()) + && errMessage.equals(details.getMessage()); + })); + } + } + + @Nested + @DisplayName("provider events") + class ProviderEvents { + + @Test + @DisplayName("should propagate events when provider set before client retrieved") + @Specification(number = "5.1.2", text = "When a provider signals the occurrence of a particular event, the associated client and API event handlers MUST run.") + void shouldPropagateBefore() { + final Consumer handler = mockHandler(); + final String name = "shouldPropagateBefore"; + + TestEventsProvider provider = new TestEventsProvider(INIT_DELAY); + // set provider before getting a client + OpenFeatureAPI.getInstance().setProvider(name, provider); + Client client = OpenFeatureAPI.getInstance().getClient(name); + client.onProviderConfigurationChanged(handler); + + provider.mockEvent(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, EventDetails.builder().build()); + verify(handler, timeout(TIMEOUT)).accept(argThat(details -> details.getClientName().equals(name))); + } + + @Test + @DisplayName("should propagate events when provider set after client retrieved") + @Specification(number = "5.1.2", text = "When a provider signals the occurrence of a particular event, the associated client and API event handlers MUST run.") + void shouldPropagateAfter() { + + final Consumer handler = mockHandler(); + final String name = "shouldPropagateAfter"; + + TestEventsProvider provider = new TestEventsProvider(INIT_DELAY); + Client client = OpenFeatureAPI.getInstance().getClient(name); + client.onProviderConfigurationChanged(handler); + // set provider after getting a client + OpenFeatureAPI.getInstance().setProvider(name, provider); + + provider.mockEvent(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, EventDetails.builder().build()); + verify(handler, timeout(TIMEOUT)).accept(argThat(details -> details.getClientName().equals(name))); + } + + @Test + @DisplayName("should support all event types") + @Specification(number = "5.1.1", text = "The provider MAY define a mechanism for signaling the occurrence " + + "of one of a set of events, including PROVIDER_READY, PROVIDER_ERROR, " + + "PROVIDER_CONFIGURATION_CHANGED and PROVIDER_STALE, with a provider event details payload.") + @Specification(number = "5.2.1", text = "The client MUST provide a function for associating handler functions" + + + " with a particular provider event type.") + void shouldSupportAllEventTypes() throws Exception { + final String name = "shouldSupportAllEventTypes"; + final Consumer handler1 = mockHandler(); + final Consumer handler2 = mockHandler(); + final Consumer handler3 = mockHandler(); + final Consumer handler4 = mockHandler(); + + TestEventsProvider provider = new TestEventsProvider(INIT_DELAY); + OpenFeatureAPI.getInstance().setProvider(name, provider); + Client client = OpenFeatureAPI.getInstance().getClient(name); + + client.onProviderReady(handler1); + client.onProviderConfigurationChanged(handler2); + client.onProviderStale(handler3); + client.onProviderError(handler4); + + Arrays.asList(ProviderEvent.values()).stream().forEach(eventType -> { + provider.mockEvent(eventType, ProviderEventDetails.builder().build()); + }); + ArgumentMatcher nameMatches = (EventDetails details) -> details.getClientName() + .equals(name); + verify(handler1, timeout(TIMEOUT).atLeastOnce()).accept(argThat(nameMatches)); + verify(handler2, timeout(TIMEOUT).atLeastOnce()).accept(argThat(nameMatches)); + verify(handler3, timeout(TIMEOUT).atLeastOnce()).accept(argThat(nameMatches)); + verify(handler4, timeout(TIMEOUT).atLeastOnce()).accept(argThat(nameMatches)); + } + } + } + + @Test + @DisplayName("shutdown provider should not run handlers") + void shouldNotRunHandlers() throws Exception { + final Consumer handler1 = mockHandler(); + final Consumer handler2 = mockHandler(); + final String name = "shouldNotRunHandlers"; + + TestEventsProvider provider1 = new TestEventsProvider(INIT_DELAY); + TestEventsProvider provider2 = new TestEventsProvider(INIT_DELAY); + OpenFeatureAPI.getInstance().setProvider(name, provider1); + Client client = OpenFeatureAPI.getInstance().getClient(name); + + // attached handlers + OpenFeatureAPI.getInstance().onProviderConfigurationChanged(handler1); + client.onProviderConfigurationChanged(handler2); + + OpenFeatureAPI.getInstance().setProvider(name, provider2); + + // wait for the new provider to be ready and make sure things are cleaned up. + await().until(() -> provider1.isShutDown()); + + // fire old event + provider1.mockEvent(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, EventDetails.builder().build()); + + // a bit of waiting here, but we want to make sure these are indeed never + // called. + verify(handler1, after(TIMEOUT).never()).accept(any()); + verify(handler2, never()).accept(any()); + } + + @Test + @DisplayName("other client handlers should not run") + @Specification(number = "5.1.3", text = "When a provider signals the occurrence of a particular event, " + + "event handlers on clients which are not associated with that provider MUST NOT run.") + void otherClientHandlersShouldNotRun() throws Exception { + final String name1 = "otherClientHandlersShouldNotRun1"; + final String name2 = "otherClientHandlersShouldNotRun2"; + final Consumer handlerToRun = mockHandler(); + final Consumer handlerNotToRun = mockHandler(); + + TestEventsProvider provider1 = new TestEventsProvider(INIT_DELAY); + TestEventsProvider provider2 = new TestEventsProvider(INIT_DELAY); + OpenFeatureAPI.getInstance().setProvider(name1, provider1); + OpenFeatureAPI.getInstance().setProvider(name2, provider2); + + Client client1 = OpenFeatureAPI.getInstance().getClient(name1); + Client client2 = OpenFeatureAPI.getInstance().getClient(name2); + + client1.onProviderConfigurationChanged(handlerToRun); + client2.onProviderConfigurationChanged(handlerNotToRun); + + provider1.mockEvent(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, ProviderEventDetails.builder().build()); + + verify(handlerToRun, timeout(TIMEOUT)).accept(any()); + verify(handlerNotToRun, never()).accept(any()); + } + + @Test + @DisplayName("bound named client handlers should not run with default") + @Specification(number = "5.1.3", text = "When a provider signals the occurrence of a particular event, " + + "event handlers on clients which are not associated with that provider MUST NOT run.") + void boundShouldNotRunWithDefault() throws Exception { + final String name = "boundShouldNotRunWithDefault"; + final Consumer handlerNotToRun = mockHandler(); + + TestEventsProvider namedProvider = new TestEventsProvider(INIT_DELAY); + TestEventsProvider defaultProvider = new TestEventsProvider(INIT_DELAY); + OpenFeatureAPI.getInstance().setProvider(defaultProvider); + + Client client = OpenFeatureAPI.getInstance().getClient(name); + client.onProviderConfigurationChanged(handlerNotToRun); + OpenFeatureAPI.getInstance().setProvider(name, namedProvider); + + // await the new provider to make sure the old one is shut down + await().until(() -> namedProvider.getState().equals(ProviderState.READY)); + + // fire event on default provider + defaultProvider.mockEvent(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, ProviderEventDetails.builder().build()); + + verify(handlerNotToRun, after(TIMEOUT).never()).accept(any()); + OpenFeatureAPI.getInstance().setProvider(new NoOpProvider()); + } + + @Test + @DisplayName("unbound named client handlers should run with default") + @Specification(number = "5.1.3", text = "When a provider signals the occurrence of a particular event, " + + "event handlers on clients which are not associated with that provider MUST NOT run.") + void unboundShouldRunWithDefault() throws Exception { + final String name = "unboundShouldRunWithDefault"; + final Consumer handlerToRun = mockHandler(); + + TestEventsProvider defaultProvider = new TestEventsProvider(INIT_DELAY); + OpenFeatureAPI.getInstance().setProvider(defaultProvider); + + Client client = OpenFeatureAPI.getInstance().getClient(name); + client.onProviderConfigurationChanged(handlerToRun); + + // await the new provider to make sure the old one is shut down + await().until(() -> defaultProvider.getState().equals(ProviderState.READY)); + + // fire event on default provider + defaultProvider.mockEvent(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, ProviderEventDetails.builder().build()); + + verify(handlerToRun, timeout(TIMEOUT)).accept(any()); + OpenFeatureAPI.getInstance().setProvider(new NoOpProvider()); + } + + @Test + @DisplayName("subsequent handlers run if earlier throws") + @Specification(number = "5.2.5", text = "If a handler function terminates abnormally, other handler functions MUST run.") + void handlersRunIfOneThrows() throws Exception { + final String name = "handlersRunIfOneThrows"; + final Consumer errorHandler = mockHandler(); + doThrow(new NullPointerException()).when(errorHandler).accept(any()); + final Consumer nextHandler = mockHandler(); + final Consumer lastHandler = mockHandler(); + + TestEventsProvider provider = new TestEventsProvider(INIT_DELAY); + OpenFeatureAPI.getInstance().setProvider(name, provider); + + Client client1 = OpenFeatureAPI.getInstance().getClient(name); + + client1.onProviderConfigurationChanged(errorHandler); + client1.onProviderConfigurationChanged(nextHandler); + client1.onProviderConfigurationChanged(lastHandler); + + provider.mockEvent(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, ProviderEventDetails.builder().build()); + verify(errorHandler, timeout(TIMEOUT)).accept(any()); + verify(nextHandler, timeout(TIMEOUT)).accept(any()); + verify(lastHandler, timeout(TIMEOUT)).accept(any()); + } + + @Test + @DisplayName("should have all properties") + @Specification(number = "5.2.4", text = "The handler function MUST accept a event details parameter.") + @Specification(number = "5.2.3", text = "The event details MUST contain the client name associated with the event.") + void shouldHaveAllProperties() throws Exception { + final Consumer handler1 = mockHandler(); + final Consumer handler2 = mockHandler(); + final String name = "shouldHaveAllProperties"; + + TestEventsProvider provider = new TestEventsProvider(INIT_DELAY); + OpenFeatureAPI.getInstance().setProvider(name, provider); + Client client = OpenFeatureAPI.getInstance().getClient(name); + + // attached handlers + OpenFeatureAPI.getInstance().onProviderConfigurationChanged(handler1); + client.onProviderConfigurationChanged(handler2); + + List flagsChanged = Arrays.asList("flag"); + ImmutableMetadata metadata = ImmutableMetadata.builder().addInteger("int", 1).build(); + String message = "a message"; + ProviderEventDetails details = ProviderEventDetails.builder() + .eventMetadata(metadata) + .flagsChanged(flagsChanged) + .message(message) + .build(); + + provider.mockEvent(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, details); + + // both global and client handler should have all the fields. + verify(handler1, timeout(TIMEOUT)) + .accept(argThat((EventDetails eventDetails) -> { + return metadata.equals(eventDetails.getEventMetadata()) + // TODO: issue for client name in events + && flagsChanged.equals(eventDetails.getFlagsChanged()) + && message.equals(eventDetails.getMessage()); + })); + verify(handler2, timeout(TIMEOUT)) + .accept(argThat((EventDetails eventDetails) -> { + return metadata.equals(eventDetails.getEventMetadata()) + && flagsChanged.equals(eventDetails.getFlagsChanged()) + && message.equals(eventDetails.getMessage()) + && name.equals(eventDetails.getClientName()); + })); + } + + @Test + @DisplayName("if the provider is ready handlers must run immediately") + @Specification(number = "5.3.3", text = "PROVIDER_READY handlers attached after the provider is already in a ready state MUST run immediately.") + void readyMustRunImmediately() throws Exception { + final String name = "readyMustRunImmediately"; + final Consumer handler = mockHandler(); + + // provider which is already ready + TestEventsProvider provider = new TestEventsProvider(ProviderState.READY); + OpenFeatureAPI.getInstance().setProvider(name, provider); + + // should run even thought handler was added after ready + Client client = OpenFeatureAPI.getInstance().getClient(name); + client.onProviderReady(handler); + verify(handler, timeout(TIMEOUT)).accept(any()); + } + + @Test + @DisplayName("must persist across changes") + @Specification(number = "5.2.6", text = "Event handlers MUST persist across provider changes.") + void mustPersistAcrossChanges() throws Exception { + final String name = "mustPersistAcrossChanges"; + final Consumer handler = mockHandler(); + + TestEventsProvider provider1 = new TestEventsProvider(INIT_DELAY); + TestEventsProvider provider2 = new TestEventsProvider(INIT_DELAY); + + OpenFeatureAPI.getInstance().setProvider(name, provider1); + Client client = OpenFeatureAPI.getInstance().getClient(name); + client.onProviderConfigurationChanged(handler); + + provider1.mockEvent(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, ProviderEventDetails.builder().build()); + ArgumentMatcher nameMatches = (EventDetails details) -> details.getClientName().equals(name); + + verify(handler, timeout(TIMEOUT).times(1)).accept(argThat(nameMatches)); + + // wait for the new provider to be ready. + OpenFeatureAPI.getInstance().setProvider(name, provider2); + await().until(() -> provider2.getState().equals(ProviderState.READY)); + + // verify that with the new provider under the same name, the handler is called + // again. + provider2.mockEvent(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, ProviderEventDetails.builder().build()); + verify(handler, timeout(TIMEOUT).times(2)).accept(argThat(nameMatches)); + } + + @Nested + class HandlerRemoval { + @Test + @DisplayName("should not run removed events") + void removedEventsShouldNotRun() { + final String name = "removedEventsShouldNotRun"; + final Consumer handler1 = mockHandler(); + final Consumer handler2 = mockHandler(); + + TestEventsProvider provider = new TestEventsProvider(INIT_DELAY); + OpenFeatureAPI.getInstance().setProvider(name, provider); + Client client = OpenFeatureAPI.getInstance().getClient(name); + + // attached handlers + OpenFeatureAPI.getInstance().onProviderStale(handler1); + client.onProviderConfigurationChanged(handler2); + + OpenFeatureAPI.getInstance().removeHandler(ProviderEvent.PROVIDER_STALE, handler1); + client.removeHandler(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, handler2); + + // emit event + provider.mockEvent(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, ProviderEventDetails.builder().build()); + + // both global and client handlers should not run. + verify(handler1, after(TIMEOUT).never()).accept(any()); + verify(handler2, never()).accept(any()); + } + } + + @Specification(number = "5.1.4", text = "PROVIDER_ERROR events SHOULD populate the provider event details's error message field.") + @Test + void thisIsAProviderRequirement() { + } + + @SuppressWarnings("unchecked") + private static Consumer mockHandler() { + return mock(Consumer.class); + } +} diff --git a/src/test/java/dev/openfeature/sdk/FlagEvaluationDetailsTest.java b/src/test/java/dev/openfeature/sdk/FlagEvaluationDetailsTest.java new file mode 100644 index 00000000..9a6df1a6 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/FlagEvaluationDetailsTest.java @@ -0,0 +1,48 @@ +package dev.openfeature.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class FlagEvaluationDetailsTest { + + @Test + @DisplayName("Should have empty constructor") + public void empty() { + FlagEvaluationDetails details = new FlagEvaluationDetails(); + assertNotNull(details); + } + + @Test + @DisplayName("Should have flagKey, value, variant, reason, errorCode, errorMessage, metadata constructor") + // removeing this constructor is a breaking change! + public void sevenArgConstructor() { + + String flagKey = "my-flag"; + Integer value = 100; + String variant = "1-hundred"; + Reason reason = Reason.DEFAULT; + ErrorCode errorCode = ErrorCode.GENERAL; + String errorMessage = "message"; + ImmutableMetadata metadata = ImmutableMetadata.builder().build(); + + FlagEvaluationDetails details = new FlagEvaluationDetails<>( + flagKey, + value, + variant, + reason.toString(), + errorCode, + errorMessage, + metadata); + + assertEquals(flagKey, details.getFlagKey()); + assertEquals(value, details.getValue()); + assertEquals(variant, details.getVariant()); + assertEquals(reason.toString(), details.getReason()); + assertEquals(errorCode, details.getErrorCode()); + assertEquals(errorMessage, details.getErrorMessage()); + assertEquals(metadata, details.getFlagMetadata()); + } +} diff --git a/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java b/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java index e246c6d6..eb41fd95 100644 --- a/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java +++ b/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java @@ -1,7 +1,8 @@ package dev.openfeature.sdk; +import static dev.openfeature.sdk.DoSomethingProvider.flagMetadata; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.InstanceOfAssertFactories.optional; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; @@ -12,36 +13,38 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import java.io.Serializable; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; - -import dev.openfeature.sdk.exceptions.FlagNotFoundError; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; - -import dev.openfeature.sdk.fixtures.HookFixtures; import org.mockito.ArgumentMatchers; import org.mockito.Mockito; import org.simplify4u.slf4jmock.LoggerMock; import org.slf4j.Logger; +import dev.openfeature.sdk.exceptions.FlagNotFoundError; +import dev.openfeature.sdk.fixtures.HookFixtures; +import dev.openfeature.sdk.testutils.FeatureProviderTestUtils; + class FlagEvaluationSpecTest implements HookFixtures { private Logger logger; + private OpenFeatureAPI api; private Client _client() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(new NoOpProvider()); + FeatureProviderTestUtils.setFeatureProvider(new NoOpProvider()); return api.getClient(); } + @BeforeEach + void getApiInstance() { + api = OpenFeatureAPI.getInstance(); + } + @AfterEach void reset_ctx() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); api.setEvaluationContext(null); } @@ -59,26 +62,23 @@ private Client _client() { assertSame(OpenFeatureAPI.getInstance(), OpenFeatureAPI.getInstance()); } - @Specification(number="1.1.2", text="The API MUST provide a function to set the global provider singleton, which accepts an API-conformant provider implementation.") + @Specification(number="1.1.2.1", text="The API MUST define a provider mutator, a function to set the default provider, which accepts an API-conformant provider implementation.") @Test void provider() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); FeatureProvider mockProvider = mock(FeatureProvider.class); - api.setProvider(mockProvider); - assertEquals(mockProvider, api.getProvider()); + FeatureProviderTestUtils.setFeatureProvider(mockProvider); + assertThat(api.getProvider()).isEqualTo(mockProvider); } - @Specification(number="1.1.4", text="The API MUST provide a function for retrieving the metadata field of the configured provider.") + @Specification(number="1.1.5", text="The API MUST provide a function for retrieving the metadata field of the configured provider.") @Test void provider_metadata() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(new DoSomethingProvider()); - assertEquals(DoSomethingProvider.name, api.getProviderMetadata().getName()); + FeatureProviderTestUtils.setFeatureProvider(new DoSomethingProvider()); + assertThat(api.getProviderMetadata().getName()).isEqualTo(DoSomethingProvider.name); } - @Specification(number="1.1.3", text="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.") + @Specification(number="1.1.4", text="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.") @Test void hook_addition() { Hook h1 = mock(Hook.class); Hook h2 = mock(Hook.class); - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); api.addHooks(h1); assertEquals(1, api.getHooks().size()); @@ -89,10 +89,9 @@ private Client _client() { assertEquals(h2, api.getHooks().get(1)); } - @Specification(number="1.1.5", text="The API MUST provide a function for creating a client which accepts the following options: - name (optional): A logical string identifier for the client.") + @Specification(number="1.1.6", text="The API MUST provide a function for creating a client which accepts the following options: - name (optional): A logical string identifier for the client.") @Test void namedClient() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - Client c = api.getClient("Sir Calls-a-lot"); + assertThatCode(() -> api.getClient("Sir Calls-a-lot")).doesNotThrowAnyException(); // TODO: Doesn't say that you can *get* the client name.. which seems useful? } @@ -112,8 +111,8 @@ private Client _client() { @Specification(number="1.3.1", text="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(number="1.3.2.1", text="The client SHOULD provide functions for floating-point numbers and integers, consistent with language idioms.") @Test void value_flags() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(new DoSomethingProvider()); + FeatureProviderTestUtils.setFeatureProvider(new DoSomethingProvider()); + Client c = api.getClient(); String key = "key"; @@ -145,8 +144,7 @@ private Client _client() { @Specification(number="1.4.5", text="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(number="1.4.6", text="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.") @Test void detail_flags() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(new DoSomethingProvider()); + FeatureProviderTestUtils.setFeatureProvider(new DoSomethingProvider()); Client c = api.getClient(); String key = "key"; @@ -154,6 +152,7 @@ private Client _client() { .flagKey(key) .value(false) .variant(null) + .flagMetadata(flagMetadata) .build(); assertEquals(bd, c.getBooleanDetails(key, true)); assertEquals(bd, c.getBooleanDetails(key, true, new ImmutableContext())); @@ -163,6 +162,7 @@ private Client _client() { .flagKey(key) .value("tset") .variant(null) + .flagMetadata(flagMetadata) .build(); assertEquals(sd, c.getStringDetails(key, "test")); assertEquals(sd, c.getStringDetails(key, "test", new ImmutableContext())); @@ -171,6 +171,7 @@ private Client _client() { FlagEvaluationDetails id = FlagEvaluationDetails.builder() .flagKey(key) .value(400) + .flagMetadata(flagMetadata) .build(); assertEquals(id, c.getIntegerDetails(key, 4)); assertEquals(id, c.getIntegerDetails(key, 4, new ImmutableContext())); @@ -179,6 +180,7 @@ private Client _client() { FlagEvaluationDetails dd = FlagEvaluationDetails.builder() .flagKey(key) .value(40.0) + .flagMetadata(flagMetadata) .build(); assertEquals(dd, c.getDoubleDetails(key, .4)); assertEquals(dd, c.getDoubleDetails(key, .4, new ImmutableContext())); @@ -204,8 +206,7 @@ private Client _client() { @Specification(number="1.4.7", text="In cases of abnormal execution, the `evaluation details` structure's `error code` field **MUST** contain an `error code`.") @Specification(number="1.4.12", text="In cases of abnormal execution, the `evaluation details` structure's `error message` field **MAY** contain a string containing additional details about the nature of the error.") @Test void broken_provider() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(new AlwaysBrokenProvider()); + FeatureProviderTestUtils.setFeatureProvider(new AlwaysBrokenProvider()); Client c = api.getClient(); assertFalse(c.getBooleanValue("key", false)); FlagEvaluationDetails details = c.getBooleanDetails("key", false); @@ -215,8 +216,7 @@ private Client _client() { @Specification(number="1.4.10", text="In the case of abnormal execution, the client SHOULD log an informative error message.") @Test void log_on_error() throws NotImplementedException { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(new AlwaysBrokenProvider()); + FeatureProviderTestUtils.setFeatureProvider(new AlwaysBrokenProvider()); Client c = api.getClient(); FlagEvaluationDetails result = c.getBooleanDetails("test", false); @@ -232,16 +232,14 @@ private Client _client() { Client c = _client(); assertNull(c.getMetadata().getName()); - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(new AlwaysBrokenProvider()); + FeatureProviderTestUtils.setFeatureProvider(new AlwaysBrokenProvider()); Client c2 = api.getClient("test"); assertEquals("test", c2.getMetadata().getName()); } @Specification(number="1.4.8", text="In cases of abnormal execution (network failure, unhandled error, etc) the reason field in the evaluation details SHOULD indicate an error.") @Test void reason_is_error_when_there_are_errors() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(new AlwaysBrokenProvider()); + FeatureProviderTestUtils.setFeatureProvider(new AlwaysBrokenProvider()); Client c = api.getClient(); FlagEvaluationDetails result = c.getBooleanDetails("test", false); assertEquals(Reason.ERROR.toString(), result.getReason()); @@ -250,9 +248,8 @@ private Client _client() { @Specification(number="3.2.1", text="The API, Client and invocation MUST have a method for supplying evaluation context.") @Specification(number="3.2.2", text="Evaluation context MUST be merged in the order: API (global; lowest precedence) - client - invocation - before hooks (highest precedence), with duplicate values being overwritten.") @Test void multi_layer_context_merges_correctly() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); DoSomethingProvider provider = new DoSomethingProvider(); - api.setProvider(provider); + FeatureProviderTestUtils.setFeatureProvider(provider); Map attributes = new HashMap<>(); attributes.put("common", new Value("1")); @@ -289,7 +286,7 @@ private Client _client() { @Specification(number="1.3.3", text="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.") @Test void type_system_prevents_this() {} - @Specification(number="1.1.6", text="The client creation function MUST NOT throw, or otherwise abnormally terminate.") + @Specification(number="1.1.7", text="The client creation function MUST NOT throw, or otherwise abnormally terminate.") @Test void constructor_does_not_throw() {} @Specification(number="1.4.11", text="The client SHOULD provide asynchronous or non-blocking mechanisms for flag evaluation.") diff --git a/src/test/java/dev/openfeature/sdk/FlagMetadataTest.java b/src/test/java/dev/openfeature/sdk/FlagMetadataTest.java new file mode 100644 index 00000000..c300daa0 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/FlagMetadataTest.java @@ -0,0 +1,64 @@ +package dev.openfeature.sdk; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class FlagMetadataTest { + + @Test + @DisplayName("Test metadata payload construction and retrieval") + public void builder_validation() { + // given + ImmutableMetadata flagMetadata = ImmutableMetadata.builder() + .addString("string", "string") + .addInteger("integer", 1) + .addLong("long", 1L) + .addFloat("float", 1.5f) + .addDouble("double", Double.MAX_VALUE) + .addBoolean("boolean", Boolean.FALSE) + .build(); + + // then + assertThat(flagMetadata.getString("string")).isEqualTo("string"); + assertThat(flagMetadata.getValue("string", String.class)).isEqualTo("string"); + + assertThat(flagMetadata.getInteger("integer")).isEqualTo(1); + assertThat(flagMetadata.getValue("integer", Integer.class)).isEqualTo(1); + + assertThat(flagMetadata.getLong("long")).isEqualTo(1L); + assertThat(flagMetadata.getValue("long", Long.class)).isEqualTo(1L); + + assertThat(flagMetadata.getFloat("float")).isEqualTo(1.5f); + assertThat(flagMetadata.getValue("float", Float.class)).isEqualTo(1.5f); + + assertThat(flagMetadata.getDouble("double")).isEqualTo(Double.MAX_VALUE); + assertThat(flagMetadata.getValue("double", Double.class)).isEqualTo(Double.MAX_VALUE); + + assertThat(flagMetadata.getBoolean("boolean")).isEqualTo(Boolean.FALSE); + assertThat(flagMetadata.getValue("boolean", Boolean.class)).isEqualTo(Boolean.FALSE); + } + + @Test + @DisplayName("Value type mismatch returns a null") + public void value_type_validation() { + // given + ImmutableMetadata flagMetadata = ImmutableMetadata.builder() + .addString("string", "string") + .build(); + + // then + assertThat(flagMetadata.getBoolean("string")).isNull(); + } + + @Test + @DisplayName("A null is returned if key does not exist") + public void notfound_error_validation() { + // given + ImmutableMetadata flagMetadata = ImmutableMetadata.builder().build(); + + // then + assertThat(flagMetadata.getBoolean("string")).isNull(); + } +} diff --git a/src/test/java/dev/openfeature/sdk/HookSpecTest.java b/src/test/java/dev/openfeature/sdk/HookSpecTest.java index 26a1b49e..d1daa705 100644 --- a/src/test/java/dev/openfeature/sdk/HookSpecTest.java +++ b/src/test/java/dev/openfeature/sdk/HookSpecTest.java @@ -21,7 +21,7 @@ import java.util.Map; import java.util.Optional; -import io.cucumber.java.hu.Ha; +import dev.openfeature.sdk.testutils.FeatureProviderTestUtils; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -30,7 +30,7 @@ import dev.openfeature.sdk.fixtures.HookFixtures; import lombok.SneakyThrows; -public class HookSpecTest implements HookFixtures { +class HookSpecTest implements HookFixtures { @AfterEach void emptyApiHooks() { // it's a singleton. Don't pollute each test. @@ -390,7 +390,7 @@ public void finallyAfter(HookContext ctx, Map hints) { InOrder order = inOrder(hook, provider); OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(provider); + FeatureProviderTestUtils.setFeatureProvider(provider); Client client = api.getClient(); client.getBooleanValue("key", false, new ImmutableContext(), FlagEvaluationOptions.builder().hook(hook).build()); @@ -493,7 +493,7 @@ public void finallyAfter(HookContext ctx, Map hints) { .build()); OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(provider); + FeatureProviderTestUtils.setFeatureProvider(provider); Client client = api.getClient(); client.getBooleanValue("key", false, invocationCtx, FlagEvaluationOptions.builder() @@ -551,12 +551,11 @@ public void finallyAfter(HookContext ctx, Map hints) { private Client getClient(FeatureProvider provider) { OpenFeatureAPI api = OpenFeatureAPI.getInstance(); if (provider == null) { - api.setProvider(new NoOpProvider()); + FeatureProviderTestUtils.setFeatureProvider(new NoOpProvider()); } else { - api.setProvider(provider); + FeatureProviderTestUtils.setFeatureProvider(provider); } - Client client = api.getClient(); - return client; + return api.getClient(); } @Specification(number="4.3.1", text="Hooks MUST specify at least one stage.") @@ -565,14 +564,12 @@ private Client getClient(FeatureProvider provider) { @Specification(number="4.3.8.1", text="Instead of finally, finallyAfter SHOULD be used.") @SneakyThrows @Test void doesnt_use_finally() { - try { - Hook.class.getMethod("finally", HookContext.class, Map.class); - fail("Not possible. Finally is a reserved word."); - } catch (NoSuchMethodException e) { - // expected - } + assertThatCode(() -> Hook.class.getMethod("finally", HookContext.class, Map.class)) + .as("Not possible. Finally is a reserved word.") + .isInstanceOf(NoSuchMethodException.class); - Hook.class.getMethod("finallyAfter", HookContext.class, Map.class); + assertThatCode(() -> Hook.class.getMethod("finallyAfter", HookContext.class, Map.class)) + .doesNotThrowAnyException(); } } diff --git a/src/test/java/dev/openfeature/sdk/ImmutableStructureTest.java b/src/test/java/dev/openfeature/sdk/ImmutableStructureTest.java index 49cd236a..d7453452 100644 --- a/src/test/java/dev/openfeature/sdk/ImmutableStructureTest.java +++ b/src/test/java/dev/openfeature/sdk/ImmutableStructureTest.java @@ -111,4 +111,15 @@ void GettingAMissingValueShouldReturnNull() { Object value = structure.getValue("missing"); assertNull(value); } + + @Test void objectMapTest() { + Map attrs = new HashMap<>(); + attrs.put("test", new Value(45)); + ImmutableStructure structure = new ImmutableStructure(attrs); + + Map expected = new HashMap<>(); + expected.put("test", 45); + + assertEquals(expected, structure.asObjectMap()); + } } diff --git a/src/test/java/dev/openfeature/sdk/InitializeBehaviorSpecTest.java b/src/test/java/dev/openfeature/sdk/InitializeBehaviorSpecTest.java new file mode 100644 index 00000000..0ab5e371 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/InitializeBehaviorSpecTest.java @@ -0,0 +1,85 @@ +package dev.openfeature.sdk; + +import dev.openfeature.sdk.testutils.exception.TestException; +import org.junit.jupiter.api.*; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.*; + +class InitializeBehaviorSpecTest { + + @BeforeEach + void setupTest() { + OpenFeatureAPI.getInstance().setProvider(new NoOpProvider()); + } + + @Nested + class DefaultProvider { + + @Specification(number = "1.1.2.2", text = "The `provider mutator` function MUST invoke the `initialize` " + + "function on the newly registered provider before using it to resolve flag values.") + @Test + @DisplayName("must call initialize function of the newly registered provider before using it for " + + "flag evaluation") + void mustCallInitializeFunctionOfTheNewlyRegisteredProviderBeforeUsingItForFlagEvaluation() throws Exception { + FeatureProvider featureProvider = mock(FeatureProvider.class); + doReturn(ProviderState.NOT_READY).when(featureProvider).getState(); + + OpenFeatureAPI.getInstance().setProvider(featureProvider); + + verify(featureProvider, timeout(1000)).initialize(any()); + } + + @Specification(number = "1.4.9", text = "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.") + @Test + @DisplayName("should catch exception thrown by the provider on initialization") + void shouldCatchExceptionThrownByTheProviderOnInitialization() throws Exception { + FeatureProvider featureProvider = mock(FeatureProvider.class); + doReturn(ProviderState.NOT_READY).when(featureProvider).getState(); + doThrow(TestException.class).when(featureProvider).initialize(any()); + + assertThatCode(() -> OpenFeatureAPI.getInstance().setProvider(featureProvider)) + .doesNotThrowAnyException(); + + verify(featureProvider, timeout(1000)).initialize(any()); + } + } + + @Nested + class ProviderForNamedClient { + + @Specification(number = "1.1.2.2", text = "The `provider mutator` function MUST invoke the `initialize`" + + " function on the newly registered provider before using it to resolve flag values.") + @Test + @DisplayName("must call initialize function of the newly registered named provider before using it " + + "for flag evaluation") + void mustCallInitializeFunctionOfTheNewlyRegisteredNamedProviderBeforeUsingItForFlagEvaluation() throws Exception { + FeatureProvider featureProvider = mock(FeatureProvider.class); + doReturn(ProviderState.NOT_READY).when(featureProvider).getState(); + + OpenFeatureAPI.getInstance().setProvider("clientName", featureProvider); + + verify(featureProvider, timeout(1000)).initialize(any()); + } + + @Specification(number = "1.4.9", text = "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.") + @Test + @DisplayName("should catch exception thrown by the named client provider on initialization") + void shouldCatchExceptionThrownByTheNamedClientProviderOnInitialization() throws Exception { + FeatureProvider featureProvider = mock(FeatureProvider.class); + doReturn(ProviderState.NOT_READY).when(featureProvider).getState(); + doThrow(TestException.class).when(featureProvider).initialize(any()); + + assertThatCode(() -> OpenFeatureAPI.getInstance().setProvider("clientName", featureProvider)) + .doesNotThrowAnyException(); + + verify(featureProvider, timeout(1000)).initialize(any()); + } + } +} diff --git a/src/test/java/dev/openfeature/sdk/LockingTest.java b/src/test/java/dev/openfeature/sdk/LockingTest.java index f8dceee6..d9601e85 100644 --- a/src/test/java/dev/openfeature/sdk/LockingTest.java +++ b/src/test/java/dev/openfeature/sdk/LockingTest.java @@ -6,20 +6,20 @@ import static org.mockito.Mockito.when; import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.Consumer; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import dev.openfeature.sdk.internal.AutoCloseableReentrantReadWriteLock; class LockingTest { - + private static OpenFeatureAPI api; private OpenFeatureClient client; - private AutoCloseableReentrantReadWriteLock apiContextLock; - private AutoCloseableReentrantReadWriteLock apiHooksLock; - private AutoCloseableReentrantReadWriteLock apiProviderLock; + private AutoCloseableReentrantReadWriteLock apiLock; private AutoCloseableReentrantReadWriteLock clientContextLock; private AutoCloseableReentrantReadWriteLock clientHooksLock; @@ -32,12 +32,8 @@ static void beforeAll() { void beforeEach() { client = (OpenFeatureClient) api.getClient(); - apiContextLock = setupLock(apiContextLock, mockInnerReadLock(), mockInnerWriteLock()); - apiProviderLock = setupLock(apiProviderLock, mockInnerReadLock(), mockInnerWriteLock()); - apiHooksLock = setupLock(apiHooksLock, mockInnerReadLock(), mockInnerWriteLock()); - OpenFeatureAPI.contextLock = apiContextLock; - OpenFeatureAPI.providerLock = apiProviderLock; - OpenFeatureAPI.hooksLock = apiHooksLock; + apiLock = setupLock(apiLock, mockInnerReadLock(), mockInnerWriteLock()); + OpenFeatureAPI.lock = apiLock; clientContextLock = setupLock(clientContextLock, mockInnerReadLock(), mockInnerWriteLock()); clientHooksLock = setupLock(clientHooksLock, mockInnerReadLock(), mockInnerWriteLock()); @@ -45,6 +41,101 @@ void beforeEach() { client.hooksLock = clientHooksLock; } + @Nested + class EventsLocking { + + @Nested + class Api { + + @Test + void onShouldWriteLockAndUnlock() { + Consumer handler = mock(Consumer.class); + api.on(ProviderEvent.PROVIDER_READY, handler); + verify(apiLock.writeLock()).lock(); + verify(apiLock.writeLock()).unlock(); + } + + @Test + void onProviderReadyShouldWriteLockAndUnlock() { + Consumer handler = mock(Consumer.class); + api.onProviderReady(handler); + verify(apiLock.writeLock()).lock(); + verify(apiLock.writeLock()).unlock(); + } + + @Test + void onProviderConfigurationChangedShouldWriteLockAndUnlock() { + Consumer handler = mock(Consumer.class); + api.onProviderConfigurationChanged(handler); + verify(apiLock.writeLock()).lock(); + verify(apiLock.writeLock()).unlock(); + } + + @Test + void onProviderStaleShouldWriteLockAndUnlock() { + Consumer handler = mock(Consumer.class); + api.onProviderStale(handler); + verify(apiLock.writeLock()).lock(); + verify(apiLock.writeLock()).unlock(); + } + + @Test + void onProviderErrorShouldWriteLockAndUnlock() { + Consumer handler = mock(Consumer.class); + api.onProviderError(handler); + verify(apiLock.writeLock()).lock(); + verify(apiLock.writeLock()).unlock(); + } + } + + @Nested + class Client { + + // Note that the API lock is used for adding client handlers, they are all added (indirectly) on the API object. + + @Test + void onShouldApiWriteLockAndUnlock() { + Consumer handler = mock(Consumer.class); + client.on(ProviderEvent.PROVIDER_READY, handler); + verify(apiLock.writeLock()).lock(); + verify(apiLock.writeLock()).unlock(); + } + + @Test + void onProviderReadyShouldApiWriteLockAndUnlock() { + Consumer handler = mock(Consumer.class); + api.onProviderReady(handler); + verify(apiLock.writeLock()).lock(); + verify(apiLock.writeLock()).unlock(); + } + + @Test + void onProviderConfigurationChangedProviderReadyShouldApiWriteLockAndUnlock() { + Consumer handler = mock(Consumer.class); + api.onProviderConfigurationChanged(handler); + verify(apiLock.writeLock()).lock(); + verify(apiLock.writeLock()).unlock(); + } + + @Test + void onProviderStaleProviderReadyShouldApiWriteLockAndUnlock() { + Consumer handler = mock(Consumer.class); + api.onProviderStale(handler); + verify(apiLock.writeLock()).lock(); + verify(apiLock.writeLock()).unlock(); + } + + @Test + void onProviderErrorProviderReadyShouldApiWriteLockAndUnlock() { + Consumer handler = mock(Consumer.class); + api.onProviderError(handler); + verify(apiLock.writeLock()).lock(); + verify(apiLock.writeLock()).unlock(); + } + } + } + + @Test void addHooksShouldWriteLockAndUnlock() { client.addHooks(new Hook() { @@ -54,8 +145,8 @@ void addHooksShouldWriteLockAndUnlock() { api.addHooks(new Hook() { }); - verify(apiHooksLock.writeLock()).lock(); - verify(apiHooksLock.writeLock()).unlock(); + verify(apiLock.writeLock()).lock(); + verify(apiLock.writeLock()).unlock(); } @Test @@ -65,8 +156,8 @@ void getHooksShouldReadLockAndUnlock() { verify(clientHooksLock.readLock()).unlock(); api.getHooks(); - verify(apiHooksLock.readLock()).lock(); - verify(apiHooksLock.readLock()).unlock(); + verify(apiLock.readLock()).lock(); + verify(apiLock.readLock()).unlock(); } @Test @@ -76,8 +167,8 @@ void setContextShouldWriteLockAndUnlock() { verify(clientContextLock.writeLock()).unlock(); api.setEvaluationContext(new ImmutableContext()); - verify(apiContextLock.writeLock()).lock(); - verify(apiContextLock.writeLock()).unlock(); + verify(apiLock.writeLock()).lock(); + verify(apiLock.writeLock()).unlock(); } @Test @@ -87,22 +178,16 @@ void getContextShouldReadLockAndUnlock() { verify(clientContextLock.readLock()).unlock(); api.getEvaluationContext(); - verify(apiContextLock.readLock()).lock(); - verify(apiContextLock.readLock()).unlock(); + verify(apiLock.readLock()).lock(); + verify(apiLock.readLock()).unlock(); } - @Test - void setProviderShouldWriteLockAndUnlock() { - api.setProvider(new DoSomethingProvider()); - verify(apiProviderLock.writeLock()).lock(); - verify(apiProviderLock.writeLock()).unlock(); - } @Test void clearHooksShouldWriteLockAndUnlock() { api.clearHooks(); - verify(apiHooksLock.writeLock()).lock(); - verify(apiHooksLock.writeLock()).unlock(); + verify(apiLock.writeLock()).lock(); + verify(apiLock.writeLock()).unlock(); } private static ReentrantReadWriteLock.ReadLock mockInnerReadLock() { diff --git a/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java b/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java new file mode 100644 index 00000000..a49bf643 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java @@ -0,0 +1,39 @@ +package dev.openfeature.sdk; + +import dev.openfeature.sdk.testutils.FeatureProviderTestUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +class OpenFeatureAPITest { + + private static final String CLIENT_NAME = "client name"; + + private OpenFeatureAPI api; + + @BeforeEach + void setupTest() { + api = OpenFeatureAPI.getInstance(); + } + + @Test + void namedProviderTest() { + FeatureProvider provider = new NoOpProvider(); + FeatureProviderTestUtils.setFeatureProvider("namedProviderTest", provider); + + assertThat(provider.getMetadata().getName()) + .isEqualTo(api.getProviderMetadata("namedProviderTest").getName()); + } + + @Test + void settingDefaultProviderToNullErrors() { + assertThatCode(() -> api.setProvider(null)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void settingNamedClientProviderToNullErrors() { + assertThatCode(() -> api.setProvider(CLIENT_NAME, null)).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java b/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java index ac150677..9036576d 100644 --- a/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java +++ b/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java @@ -27,7 +27,7 @@ class OpenFeatureClientTest implements HookFixtures { @DisplayName("should not throw exception if hook has different type argument than hookContext") void shouldNotThrowExceptionIfHookHasDifferentTypeArgumentThanHookContext() { OpenFeatureAPI api = mock(OpenFeatureAPI.class); - when(api.getProvider()).thenReturn(new DoSomethingProvider()); + when(api.getProvider(any())).thenReturn(new DoSomethingProvider()); when(api.getHooks()).thenReturn(Arrays.asList(mockBooleanHook(), mockStringHook())); OpenFeatureClient client = new OpenFeatureClient(api, "name", "version"); @@ -57,6 +57,8 @@ void mergeContextTest() { context -> context.getTargetingKey().equals(targetingKey)))).thenReturn(ProviderEvaluation.builder() .value(true).build()); when(api.getProvider()).thenReturn(mockProvider); + when(api.getProvider(any())).thenReturn(mockProvider); + OpenFeatureClient client = new OpenFeatureClient(api, "name", "version"); client.setEvaluationContext(ctx); diff --git a/src/test/java/dev/openfeature/sdk/ProviderEvaluationTest.java b/src/test/java/dev/openfeature/sdk/ProviderEvaluationTest.java new file mode 100644 index 00000000..16215dc1 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/ProviderEvaluationTest.java @@ -0,0 +1,45 @@ +package dev.openfeature.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class ProviderEvaluationTest { + + @Test + @DisplayName("Should have empty constructor") + public void empty() { + ProviderEvaluation details = new ProviderEvaluation(); + assertNotNull(details); + } + + @Test + @DisplayName("Should have value, variant, reason, errorCode, errorMessage, metadata constructor") + // removeing this constructor is a breaking change! + public void sixArgConstructor() { + + Integer value = 100; + String variant = "1-hundred"; + Reason reason = Reason.DEFAULT; + ErrorCode errorCode = ErrorCode.GENERAL; + String errorMessage = "message"; + ImmutableMetadata metadata = ImmutableMetadata.builder().build(); + + ProviderEvaluation details = new ProviderEvaluation<>( + value, + variant, + reason.toString(), + errorCode, + errorMessage, + metadata); + + assertEquals(value, details.getValue()); + assertEquals(variant, details.getVariant()); + assertEquals(reason.toString(), details.getReason()); + assertEquals(errorCode, details.getErrorCode()); + assertEquals(errorMessage, details.getErrorMessage()); + assertEquals(metadata, details.getFlagMetadata()); + } +} diff --git a/src/test/java/dev/openfeature/sdk/ProviderRepositoryTest.java b/src/test/java/dev/openfeature/sdk/ProviderRepositoryTest.java new file mode 100644 index 00000000..5b6dac1b --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/ProviderRepositoryTest.java @@ -0,0 +1,364 @@ +package dev.openfeature.sdk; + +import static dev.openfeature.sdk.fixtures.ProviderFixture.createMockedErrorProvider; +import static dev.openfeature.sdk.fixtures.ProviderFixture.createMockedProvider; +import static dev.openfeature.sdk.fixtures.ProviderFixture.createMockedReadyProvider; +import static dev.openfeature.sdk.testutils.stubbing.ConditionStubber.doDelayResponse; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; + +import java.time.Duration; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import dev.openfeature.sdk.testutils.exception.TestException; + +class ProviderRepositoryTest { + + private static final String CLIENT_NAME = "client name"; + private static final String ANOTHER_CLIENT_NAME = "another client name"; + private static final int TIMEOUT = 5000; + + private final ExecutorService executorService = Executors.newCachedThreadPool(); + + private ProviderRepository providerRepository; + + @BeforeEach + void setupTest() { + providerRepository = new ProviderRepository(); + } + + @Nested + class InitializationBehavior { + + @Nested + class DefaultProvider { + + @Test + @DisplayName("should reject null as default provider") + void shouldRejectNullAsDefaultProvider() { + assertThatCode(() -> providerRepository.setProvider(null, mockAfterSet(), mockAfterInit(), + mockAfterShutdown(), mockAfterError())).isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("should have NoOpProvider set as default on initialization") + void shouldHaveNoOpProviderSetAsDefaultOnInitialization() { + assertThat(providerRepository.getProvider()).isInstanceOf(NoOpProvider.class); + } + + @Test + @DisplayName("should immediately return when calling the provider mutator") + void shouldImmediatelyReturnWhenCallingTheProviderMutator() throws Exception { + FeatureProvider featureProvider = createMockedProvider(); + doDelayResponse(Duration.ofSeconds(10)).when(featureProvider).initialize(new ImmutableContext()); + + await() + .alias("wait for provider mutator to return") + .pollDelay(Duration.ofMillis(1)) + .atMost(Duration.ofSeconds(1)) + .until(() -> { + providerRepository.setProvider(featureProvider, mockAfterSet(), mockAfterInit(), + mockAfterShutdown(), mockAfterError()); + verify(featureProvider, timeout(TIMEOUT)).initialize(any()); + return true; + }); + + verify(featureProvider, timeout(TIMEOUT)).initialize(any()); + } + + @Test + @DisplayName("should avoid additional initialization call if provider has been initialized already") + void shouldAvoidAdditionalInitializationCallIfProviderHasBeenInitializedAlready() throws Exception { + FeatureProvider provider = createMockedReadyProvider(); + setFeatureProvider(provider); + + verify(provider, never()).initialize(any()); + } + } + + @Nested + class NamedProvider { + + @Test + @DisplayName("should reject null as named provider") + void shouldRejectNullAsNamedProvider() { + assertThatCode(() -> providerRepository.setProvider(CLIENT_NAME, null, mockAfterSet(), mockAfterInit(), + mockAfterShutdown(), mockAfterError())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("should reject null as client name") + void shouldRejectNullAsDefaultProvider() { + NoOpProvider provider = new NoOpProvider(); + assertThatCode(() -> providerRepository.setProvider(null, provider, mockAfterSet(), mockAfterInit(), + mockAfterShutdown(), mockAfterError())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("should immediately return when calling the named client provider mutator") + void shouldImmediatelyReturnWhenCallingTheNamedClientProviderMutator() throws Exception { + FeatureProvider featureProvider = createMockedProvider(); + doDelayResponse(Duration.ofSeconds(10)).when(featureProvider).initialize(any()); + + await() + .alias("wait for provider mutator to return") + .pollDelay(Duration.ofMillis(1)) + .atMost(Duration.ofSeconds(1)) + .until(() -> { + providerRepository.setProvider("named client", featureProvider, mockAfterSet(), + mockAfterInit(), mockAfterShutdown(), mockAfterError()); + verify(featureProvider, timeout(TIMEOUT)).initialize(any()); + return true; + }); + } + + @Test + @DisplayName("should avoid additional initialization call if provider has been initialized already") + void shouldAvoidAdditionalInitializationCallIfProviderHasBeenInitializedAlready() throws Exception { + FeatureProvider provider = createMockedReadyProvider(); + setFeatureProvider(CLIENT_NAME, provider); + + verify(provider, never()).initialize(any()); + } + } + } + + @Nested + class ShutdownBehavior { + + @Nested + class DefaultProvider { + + @Test + @DisplayName("should immediately return when calling the provider mutator") + void shouldImmediatelyReturnWhenCallingTheProviderMutator() throws Exception { + FeatureProvider newProvider = createMockedProvider(); + doDelayResponse(Duration.ofSeconds(10)).when(newProvider).initialize(any()); + + await() + .alias("wait for provider mutator to return") + .pollDelay(Duration.ofMillis(1)) + .atMost(Duration.ofSeconds(1)) + .until(() -> { + providerRepository.setProvider(newProvider, mockAfterSet(), mockAfterInit(), + mockAfterShutdown(), mockAfterError()); + verify(newProvider, timeout(TIMEOUT)).initialize(any()); + return true; + }); + + verify(newProvider, timeout(TIMEOUT)).initialize(any()); + } + + @Test + @DisplayName("should not call shutdown if replaced default provider is bound as named provider") + void shouldNotCallShutdownIfReplacedDefaultProviderIsBoundAsNamedProvider() { + FeatureProvider oldProvider = createMockedProvider(); + FeatureProvider newProvider = createMockedProvider(); + setFeatureProvider(oldProvider); + setFeatureProvider(CLIENT_NAME, oldProvider); + + setFeatureProvider(newProvider); + + verify(oldProvider, never()).shutdown(); + } + } + + @Nested + class NamedProvider { + + @Test + @DisplayName("should immediately return when calling the provider mutator") + void shouldImmediatelyReturnWhenCallingTheProviderMutator() throws Exception { + FeatureProvider newProvider = createMockedProvider(); + doDelayResponse(Duration.ofSeconds(10)).when(newProvider).initialize(any()); + + Future providerMutation = executorService + .submit(() -> providerRepository.setProvider(CLIENT_NAME, newProvider, mockAfterSet(), + mockAfterInit(), mockAfterShutdown(), mockAfterError())); + + await() + .alias("wait for provider mutator to return") + .pollDelay(Duration.ofMillis(1)) + .atMost(Duration.ofSeconds(1)) + .until(providerMutation::isDone); + } + + @Test + @DisplayName("should not call shutdown if replaced provider is bound to multiple names") + void shouldNotCallShutdownIfReplacedProviderIsBoundToMultipleNames() throws InterruptedException { + FeatureProvider oldProvider = createMockedProvider(); + FeatureProvider newProvider = createMockedProvider(); + setFeatureProvider(CLIENT_NAME, oldProvider); + + setFeatureProvider(ANOTHER_CLIENT_NAME, oldProvider); + + setFeatureProvider(CLIENT_NAME, newProvider); + + verify(oldProvider, never()).shutdown(); + } + + @Test + @DisplayName("should not call shutdown if replaced provider is bound as default provider") + void shouldNotCallShutdownIfReplacedProviderIsBoundAsDefaultProvider() { + FeatureProvider oldProvider = createMockedProvider(); + FeatureProvider newProvider = createMockedProvider(); + setFeatureProvider(oldProvider); + setFeatureProvider(CLIENT_NAME, oldProvider); + + setFeatureProvider(CLIENT_NAME, newProvider); + + verify(oldProvider, never()).shutdown(); + } + + @Test + @DisplayName("should not throw exception if provider throws one on shutdown") + void shouldNotThrowExceptionIfProviderThrowsOneOnShutdown() { + FeatureProvider provider = createMockedProvider(); + doThrow(TestException.class).when(provider).shutdown(); + setFeatureProvider(provider); + + assertThatCode(() -> setFeatureProvider(new NoOpProvider())).doesNotThrowAnyException(); + + verify(provider, timeout(TIMEOUT)).shutdown(); + } + } + + @Nested + class LifecyleLambdas { + @Test + @DisplayName("should run afterSet, afterInit, afterShutdown on successful set/init") + @SuppressWarnings("unchecked") + void shouldRunLambdasOnSuccessful() { + Consumer afterSet = mock(Consumer.class); + Consumer afterInit = mock(Consumer.class); + Consumer afterShutdown = mock(Consumer.class); + BiConsumer afterError = mock(BiConsumer.class); + + FeatureProvider oldProvider = providerRepository.getProvider(); + FeatureProvider featureProvider1 = createMockedProvider(); + FeatureProvider featureProvider2 = createMockedProvider(); + + setFeatureProvider(featureProvider1, afterSet, afterInit, afterShutdown, afterError); + setFeatureProvider(featureProvider2); + verify(afterSet, timeout(TIMEOUT)).accept(featureProvider1); + verify(afterInit, timeout(TIMEOUT)).accept(featureProvider1); + verify(afterShutdown, timeout(TIMEOUT)).accept(oldProvider); + verify(afterError, never()).accept(any(), any()); + } + + @Test + @DisplayName("should run afterSet, afterError on unsuccessful set/init") + @SuppressWarnings("unchecked") + void shouldRunLambdasOnError() throws Exception { + Consumer afterSet = mock(Consumer.class); + Consumer afterInit = mock(Consumer.class); + Consumer afterShutdown = mock(Consumer.class); + BiConsumer afterError = mock(BiConsumer.class); + + FeatureProvider errorFeatureProvider = createMockedErrorProvider(); + + setFeatureProvider(errorFeatureProvider, afterSet, afterInit, afterShutdown, afterError); + verify(afterSet, timeout(TIMEOUT)).accept(errorFeatureProvider); + verify(afterInit, never()).accept(any());; + verify(afterError, timeout(TIMEOUT)).accept(eq(errorFeatureProvider), any()); + } + } + } + + @Test + @DisplayName("should shutdown all feature providers on shutdown") + void shouldShutdownAllFeatureProvidersOnShutdown() { + FeatureProvider featureProvider1 = createMockedProvider(); + FeatureProvider featureProvider2 = createMockedProvider(); + + setFeatureProvider(featureProvider1); + setFeatureProvider(CLIENT_NAME, featureProvider1); + setFeatureProvider(ANOTHER_CLIENT_NAME, featureProvider2); + + providerRepository.shutdown(); + + await() + .pollDelay(Duration.ofMillis(1)) + .atMost(Duration.ofSeconds(TIMEOUT)) + .untilAsserted(() -> { + assertThat(providerRepository.getProvider()).isInstanceOf(NoOpProvider.class); + assertThat(providerRepository.getProvider(CLIENT_NAME)).isInstanceOf(NoOpProvider.class); + assertThat(providerRepository.getProvider(ANOTHER_CLIENT_NAME)).isInstanceOf(NoOpProvider.class); + }); + verify(featureProvider1, timeout(TIMEOUT)).shutdown(); + verify(featureProvider2, timeout(TIMEOUT)).shutdown(); + } + + private void setFeatureProvider(FeatureProvider provider) { + providerRepository.setProvider(provider, mockAfterSet(), mockAfterInit(), mockAfterShutdown(), + mockAfterError()); + waitForSettingProviderHasBeenCompleted(ProviderRepository::getProvider, provider); + } + + + private void setFeatureProvider(FeatureProvider provider, Consumer afterSet, + Consumer afterInit, Consumer afterShutdown, + BiConsumer afterError) { + providerRepository.setProvider(provider, afterSet, afterInit, afterShutdown, + afterError); + waitForSettingProviderHasBeenCompleted(ProviderRepository::getProvider, provider); + } + + private void setFeatureProvider(String namedProvider, FeatureProvider provider) { + providerRepository.setProvider(namedProvider, provider, mockAfterSet(), mockAfterInit(), mockAfterShutdown(), + mockAfterError()); + waitForSettingProviderHasBeenCompleted(repository -> repository.getProvider(namedProvider), provider); + } + + private void waitForSettingProviderHasBeenCompleted( + Function extractor, + FeatureProvider provider) { + await() + .pollDelay(Duration.ofMillis(1)) + .atMost(Duration.ofSeconds(5)) + .until(() -> { + return extractor.apply(providerRepository) == provider; + }); + } + + private Consumer mockAfterSet() { + return fp -> { + }; + } + + private Consumer mockAfterInit() { + return fp -> { + }; + } + + private Consumer mockAfterShutdown() { + return fp -> { + }; + } + + private BiConsumer mockAfterError() { + return (fp, message) -> { + }; + } + +} diff --git a/src/test/java/dev/openfeature/sdk/ProviderSpecTest.java b/src/test/java/dev/openfeature/sdk/ProviderSpecTest.java index 31a6a5e8..f5e5e6a4 100644 --- a/src/test/java/dev/openfeature/sdk/ProviderSpecTest.java +++ b/src/test/java/dev/openfeature/sdk/ProviderSpecTest.java @@ -18,7 +18,7 @@ void name_accessor() { @Specification(number = "2.2.2.1", text = "The feature provider interface MUST define methods for typed " + "flag resolution, including boolean, numeric, string, and structure.") @Specification(number = "2.2.3", text = "In cases of normal execution, the `provider` MUST populate the `resolution details` structure's `value` field with the resolved flag value.") - @Specification(number = "2.2.1", text = "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(number = "2.2.1", text = "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(number = "2.2.8.1", text = "The `resolution details` structure SHOULD accept a generic argument (or use an equivalent language feature) which indicates the type of the wrapped `value` field.") @Test void flag_value_set() { diff --git a/src/test/java/dev/openfeature/sdk/ShutdownBehaviorSpecTest.java b/src/test/java/dev/openfeature/sdk/ShutdownBehaviorSpecTest.java new file mode 100644 index 00000000..e470819f --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/ShutdownBehaviorSpecTest.java @@ -0,0 +1,117 @@ +package dev.openfeature.sdk; + +import dev.openfeature.sdk.fixtures.ProviderFixture; +import dev.openfeature.sdk.testutils.exception.TestException; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static dev.openfeature.sdk.testutils.FeatureProviderTestUtils.setFeatureProvider; +import static org.mockito.Mockito.*; + +class ShutdownBehaviorSpecTest { + + @BeforeEach + void resetFeatureProvider() { + setFeatureProvider(new NoOpProvider()); + } + + @Nested + class DefaultProvider { + + @Specification(number = "1.1.2.3", text = "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.") + @Test + @DisplayName("must invoke shutdown method on previously registered provider once it should not be used for flag evaluation anymore") + void mustInvokeShutdownMethodOnPreviouslyRegisteredProviderOnceItShouldNotBeUsedForFlagEvaluationAnymore() { + FeatureProvider featureProvider = ProviderFixture.createMockedProvider(); + + setFeatureProvider(featureProvider); + setFeatureProvider(new NoOpProvider()); + + verify(featureProvider, timeout(1000)).shutdown(); + } + + @Specification(number = "1.4.9", text = "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.") + @Test + @DisplayName("should catch exception thrown by the provider on shutdown") + void shouldCatchExceptionThrownByTheProviderOnShutdown() { + FeatureProvider featureProvider = ProviderFixture.createMockedProvider(); + doThrow(TestException.class).when(featureProvider).shutdown(); + + setFeatureProvider(featureProvider); + setFeatureProvider(new NoOpProvider()); + + verify(featureProvider, timeout(1000)).shutdown(); + } + } + + @Nested + class NamedProvider { + + @Specification(number = "1.1.2.3", text = "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.") + @Test + @DisplayName("must invoke shutdown method on previously registered provider once it should not be used for flag evaluation anymore") + void mustInvokeShutdownMethodOnPreviouslyRegisteredProviderOnceItShouldNotBeUsedForFlagEvaluationAnymore() { + String clientName = "clientName"; + FeatureProvider featureProvider = ProviderFixture.createMockedProvider(); + + setFeatureProvider(clientName, featureProvider); + setFeatureProvider(clientName, new NoOpProvider()); + + verify(featureProvider, timeout(1000)).shutdown(); + } + + @Specification(number = "1.4.9", text = "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.") + @Test + @DisplayName("should catch exception thrown by the named client provider on shutdown") + void shouldCatchExceptionThrownByTheNamedClientProviderOnShutdown() { + String clientName = "clientName"; + FeatureProvider featureProvider = ProviderFixture.createMockedProvider(); + doThrow(TestException.class).when(featureProvider).shutdown(); + + setFeatureProvider(clientName, featureProvider); + setFeatureProvider(clientName, new NoOpProvider()); + + verify(featureProvider, timeout(1000)).shutdown(); + } + } + + @Nested + class General { + + @Specification(number = "1.6.1", text = "The API MUST define a mechanism to propagate a shutdown request to active providers.") + @Test + @DisplayName("must shutdown all providers on shutting down api") + void mustShutdownAllProvidersOnShuttingDownApi() { + FeatureProvider defaultProvider = ProviderFixture.createMockedProvider(); + FeatureProvider namedProvider = ProviderFixture.createMockedProvider(); + setFeatureProvider(defaultProvider); + setFeatureProvider("clientName", namedProvider); + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + + synchronized (OpenFeatureAPI.class) { + api.shutdown(); + + Awaitility + .await() + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> { + verify(defaultProvider).shutdown(); + verify(namedProvider).shutdown(); + }); + + api.reset(); + } + } + } +} diff --git a/src/test/java/dev/openfeature/sdk/integration/RunCucumberTest.java b/src/test/java/dev/openfeature/sdk/e2e/RunCucumberTest.java similarity index 92% rename from src/test/java/dev/openfeature/sdk/integration/RunCucumberTest.java rename to src/test/java/dev/openfeature/sdk/e2e/RunCucumberTest.java index 6a13ed29..2c652338 100644 --- a/src/test/java/dev/openfeature/sdk/integration/RunCucumberTest.java +++ b/src/test/java/dev/openfeature/sdk/e2e/RunCucumberTest.java @@ -1,4 +1,4 @@ -package dev.openfeature.sdk.integration; +package dev.openfeature.sdk.e2e; import org.junit.platform.suite.api.ConfigurationParameter; import org.junit.platform.suite.api.IncludeEngines; diff --git a/src/test/java/dev/openfeature/sdk/integration/StepDefinitions.java b/src/test/java/dev/openfeature/sdk/e2e/StepDefinitions.java similarity index 99% rename from src/test/java/dev/openfeature/sdk/integration/StepDefinitions.java rename to src/test/java/dev/openfeature/sdk/e2e/StepDefinitions.java index 41c4dafc..7048fc0b 100644 --- a/src/test/java/dev/openfeature/sdk/integration/StepDefinitions.java +++ b/src/test/java/dev/openfeature/sdk/e2e/StepDefinitions.java @@ -1,4 +1,4 @@ -package dev.openfeature.sdk.integration; +package dev.openfeature.sdk.e2e; import dev.openfeature.contrib.providers.flagd.FlagdProvider; import dev.openfeature.sdk.Client; diff --git a/src/test/java/dev/openfeature/sdk/fixtures/ProviderFixture.java b/src/test/java/dev/openfeature/sdk/fixtures/ProviderFixture.java new file mode 100644 index 00000000..c00b8ff2 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/fixtures/ProviderFixture.java @@ -0,0 +1,66 @@ +package dev.openfeature.sdk.fixtures; + +import static dev.openfeature.sdk.testutils.stubbing.ConditionStubber.doBlock; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; + +import java.io.FileNotFoundException; +import java.util.concurrent.CountDownLatch; + +import org.mockito.stubbing.Answer; + +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.ProviderState; +import lombok.experimental.UtilityClass; + +@UtilityClass +public class ProviderFixture { + + public static FeatureProvider createMockedProvider() { + FeatureProvider provider = mock(FeatureProvider.class); + doReturn(ProviderState.NOT_READY).when(provider).getState(); + return provider; + } + + public static FeatureProvider createMockedReadyProvider() { + FeatureProvider provider = mock(FeatureProvider.class); + doReturn(ProviderState.READY).when(provider).getState(); + return provider; + } + + public static FeatureProvider createMockedErrorProvider() throws Exception { + FeatureProvider provider = mock(FeatureProvider.class); + doReturn(ProviderState.NOT_READY).when(provider).getState(); + doThrow(FileNotFoundException.class).when(provider).initialize(any()); + return provider; + } + + public static FeatureProvider createBlockedProvider(CountDownLatch latch, Runnable onAnswer) throws Exception { + FeatureProvider provider = createMockedProvider(); + doBlock(latch, createAnswerExecutingCode(onAnswer)).when(provider).initialize(new ImmutableContext()); + doReturn("blockedProvider").when(provider).toString(); + return provider; + } + + private static Answer createAnswerExecutingCode(Runnable onAnswer) { + return invocation -> { + onAnswer.run(); + return null; + }; + } + + public static FeatureProvider createUnblockingProvider(CountDownLatch latch) throws Exception { + FeatureProvider provider = createMockedProvider(); + doAnswer(invocation -> { + latch.countDown(); + return null; + }).when(provider).initialize(new ImmutableContext()); + doReturn("unblockingProvider").when(provider).toString(); + return provider; + } + +} diff --git a/src/test/java/dev/openfeature/sdk/internal/TriConsumerTest.java b/src/test/java/dev/openfeature/sdk/internal/TriConsumerTest.java new file mode 100644 index 00000000..0c85a7cc --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/internal/TriConsumerTest.java @@ -0,0 +1,34 @@ +package dev.openfeature.sdk.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class TriConsumerTest { + + @Test + @DisplayName("should run accept") + void shouldRunAccept() { + AtomicInteger result = new AtomicInteger(0); + TriConsumer triConsumer = (num1, num2, num3) -> { + result.set(result.get() + num1 + num2 + num3); + }; + triConsumer.accept(1, 2, 3); + assertEquals(6, result.get()); + } + + @Test + @DisplayName("should run after accept") + void shouldRunAfterAccept() { + AtomicInteger result = new AtomicInteger(0); + TriConsumer triConsumer = (num1, num2, num3) -> { + result.set(result.get() + num1 + num2 + num3); + }; + TriConsumer composed = triConsumer.andThen(triConsumer); + composed.accept(1, 2, 3); + assertEquals(12, result.get()); + } +} \ No newline at end of file diff --git a/src/test/java/dev/openfeature/sdk/testutils/FeatureProviderTestUtils.java b/src/test/java/dev/openfeature/sdk/testutils/FeatureProviderTestUtils.java new file mode 100644 index 00000000..5f8c13db --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/testutils/FeatureProviderTestUtils.java @@ -0,0 +1,30 @@ +package dev.openfeature.sdk.testutils; + +import java.time.Duration; +import java.util.function.Function; + +import dev.openfeature.sdk.*; +import lombok.experimental.UtilityClass; + +import static org.awaitility.Awaitility.await; + +@UtilityClass +public class FeatureProviderTestUtils { + + public static void setFeatureProvider(FeatureProvider provider) { + OpenFeatureAPI.getInstance().setProvider(provider); + waitForProviderInitializationComplete(OpenFeatureAPI::getProvider, provider); + } + + private static void waitForProviderInitializationComplete(Function extractor, FeatureProvider provider) { + await() + .pollDelay(Duration.ofMillis(1)) + .atMost(Duration.ofSeconds(1)) + .until(() -> extractor.apply(OpenFeatureAPI.getInstance()) == provider); + } + + public static void setFeatureProvider(String namedProvider, FeatureProvider provider) { + OpenFeatureAPI.getInstance().setProvider(namedProvider, provider); + waitForProviderInitializationComplete(api -> api.getProvider(namedProvider), provider); + } +} diff --git a/src/test/java/dev/openfeature/sdk/testutils/TestEventsProvider.java b/src/test/java/dev/openfeature/sdk/testutils/TestEventsProvider.java new file mode 100644 index 00000000..3fcb5888 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/testutils/TestEventsProvider.java @@ -0,0 +1,99 @@ +package dev.openfeature.sdk.testutils; + +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.EventProvider; +import dev.openfeature.sdk.Metadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEvent; +import dev.openfeature.sdk.ProviderEventDetails; +import dev.openfeature.sdk.ProviderState; +import dev.openfeature.sdk.Value; + +public class TestEventsProvider extends EventProvider { + + private boolean initError = false; + private String initErrorMessage; + private ProviderState state = ProviderState.NOT_READY; + private boolean shutDown = false; + private int initTimeout = 0; + + @Override + public ProviderState getState() { + return this.state; + } + + public TestEventsProvider(int initTimeout) { + this.initTimeout = initTimeout; + } + + public TestEventsProvider(int initTimeout, boolean initError, String initErrorMessage) { + this.initTimeout = initTimeout; + this.initError = initError; + this.initErrorMessage = initErrorMessage; + } + + public TestEventsProvider(ProviderState initialState) { + this.state = initialState; + } + + public void mockEvent(ProviderEvent event, ProviderEventDetails details) { + emit(event, details); + } + + public boolean isShutDown() { + return this.shutDown; + } + + @Override + public void shutdown() { + this.shutDown = true; + } + + @Override + public void initialize(EvaluationContext evaluationContext) throws Exception { + if (ProviderState.NOT_READY.equals(state)) { + // wait half the TIMEOUT, otherwise some init/errors can be fired before we add handlers + Thread.sleep(initTimeout); + if (this.initError) { + this.state = ProviderState.ERROR; + throw new Exception(initErrorMessage); + } + this.state = ProviderState.READY; + } + } + + @Override + public Metadata getMetadata() { + throw new UnsupportedOperationException("Unimplemented method 'getMetadata'"); + } + + @Override + public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, + EvaluationContext ctx) { + throw new UnsupportedOperationException("Unimplemented method 'getBooleanEvaluation'"); + } + + @Override + public ProviderEvaluation getStringEvaluation(String key, String defaultValue, + EvaluationContext ctx) { + throw new UnsupportedOperationException("Unimplemented method 'getStringEvaluation'"); + } + + @Override + public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, + EvaluationContext ctx) { + throw new UnsupportedOperationException("Unimplemented method 'getIntegerEvaluation'"); + } + + @Override + public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, + EvaluationContext ctx) { + throw new UnsupportedOperationException("Unimplemented method 'getDoubleEvaluation'"); + } + + @Override + public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, + EvaluationContext ctx) { + throw new UnsupportedOperationException("Unimplemented method 'getObjectEvaluation'"); + } +}; \ No newline at end of file diff --git a/src/test/java/dev/openfeature/sdk/testutils/exception/TestException.java b/src/test/java/dev/openfeature/sdk/testutils/exception/TestException.java new file mode 100644 index 00000000..c6918b02 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/testutils/exception/TestException.java @@ -0,0 +1,9 @@ +package dev.openfeature.sdk.testutils.exception; + +public class TestException extends RuntimeException { + + @Override + public String getMessage() { + return "don't panic, it's just a test"; + } +} diff --git a/src/test/java/dev/openfeature/sdk/testutils/stubbing/ConditionStubber.java b/src/test/java/dev/openfeature/sdk/testutils/stubbing/ConditionStubber.java new file mode 100644 index 00000000..11cf2649 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/testutils/stubbing/ConditionStubber.java @@ -0,0 +1,37 @@ +package dev.openfeature.sdk.testutils.stubbing; + +import java.time.Duration; +import java.util.concurrent.CountDownLatch; + +import lombok.experimental.UtilityClass; +import org.mockito.stubbing.*; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.mockito.Mockito.doAnswer; + +@UtilityClass +public class ConditionStubber { + + @SuppressWarnings("java:S2925") + public static Stubber doDelayResponse(Duration duration) { + return doAnswer(invocation -> { + MILLISECONDS.sleep(duration.toMillis()); + return null; + }); + } + + public static Stubber doBlock(CountDownLatch latch) { + return doAnswer(invocation -> { + latch.await(); + return null; + }); + } + + public static Stubber doBlock(CountDownLatch latch, Answer answer) { + return doAnswer(invocation -> { + latch.await(); + return answer.answer(invocation); + }); + } + +} diff --git a/version.txt b/version.txt index 3a3cd8cc..88c5fb89 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.3.1 +1.4.0