diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml new file mode 100644 index 000000000..376bf574e --- /dev/null +++ b/.github/workflows/lint-pr.yml @@ -0,0 +1,17 @@ +name: 'Lint PR' + +on: + pull_request_target: + types: + - opened + - edited + - synchronize + +jobs: + main: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v4 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/merge.yml b/.github/workflows/merge.yml index 7405c947f..ed30bdbfc 100644 --- a/.github/workflows/merge.yml +++ b/.github/workflows/merge.yml @@ -33,7 +33,7 @@ jobs: server-password: ${{ secrets.OSSRH_PASSWORD }} - name: Cache local Maven repository - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} @@ -50,18 +50,21 @@ jobs: run: mvn --batch-mode --update-snapshots verify - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v3 with: flags: unittests # optional name: coverage # optional fail_ci_if_error: true # optional (default = false) verbose: true # optional (default = false) + + # Add -SNAPSHOT before deploy + - name: Add SNAPSHOT + run: mvn versions:set -DnewVersion='${project.version}-SNAPSHOT' + - name: Deploy run: | - mvn -P gpg_verify \ - --no-transfer-progress \ - --batch-mode \ - --file pom.xml -s release/m2-settings.xml verify deploy + mvn --batch-mode \ + --settings release/m2-settings.xml clean deploy env: OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index c37478aae..3b7430a0a 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -9,6 +9,12 @@ permissions: jobs: build: runs-on: ubuntu-latest + services: + flagd: + image: ghcr.io/open-feature/flagd-testbed:latest + ports: + - 8013:8013 + steps: - name: Check out the code uses: actions/checkout@v3 @@ -20,7 +26,7 @@ jobs: cache: maven - name: Cache local Maven repository - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} @@ -28,10 +34,10 @@ jobs: ${{ runner.os }}-maven- - name: Build with Maven - run: mvn --batch-mode --update-snapshots verify + run: mvn --batch-mode --update-snapshots verify # -P integration-test - add this back once we have a compatible flagd - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v3 with: flags: unittests # optional name: coverage # optional diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b729cfdd..bbabd794e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,68 +1,52 @@ -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. -# This workflow will build a package using Gradle and then publish it to GitHub packages when a release is created -# For more information see: https://github.com/actions/setup-java/blob/main/docs/advanced-usage.md#Publishing-using-gradle - -# maven deploy commands via via https://tech.clevertap.com/automate-releases-to-maven-central-via-github-actions/ - -name: Release +# This workflow creates a running release please PR, which tracks all changes +# based on semantic PR titles. When that PR is merged, a publish occurs after +# release please increments the version. on: - release: - types: [created] - + push: + branches: + - main +name: Run Release Please jobs: - build: - + release-please: runs-on: ubuntu-latest - permissions: - contents: read - packages: write + # Release-please creates a PR that tracks all changes steps: - - uses: actions/checkout@v3 - - name: Set up JDK 8 - uses: actions/setup-java@v3 - with: - java-version: '8' - distribution: 'temurin' - cache: maven - server-id: ossrh - server-username: ${{ secrets.OSSRH_USERNAME }} - server-password: ${{ secrets.OSSRH_PASSWORD }} - - - name: Configure GPG Key - run: | - echo -n "$GPG_SIGNING_KEY" | base64 --decode | gpg --import - gpg --list-secret-keys --keyid-format LONG - env: - GPG_SIGNING_KEY: ${{ secrets.GPG_SIGNING_KEY }} - - - name: Build with Maven - run: | - mvn --batch-mode --update-snapshots verify gpg:sign -Dversion.modifier='' - ls - ls * - gpg --verify target/javasdk-0.1.0-sources.jar.asc target/javasdk-0.1.0-sources.jar - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2 - with: - token: ${{ secrets.CODECOV_TOKEN }} - flags: unittests # optional - name: coverage # optional - fail_ci_if_error: true # optional (default = false) - verbose: true # optional (default = false) - - - name: Deploy - run: | - gpg --verify target/javasdk-0.1.0-sources.jar.asc target/javasdk-0.1.0-sources.jar - mvn -P gpg_verify \ - --no-transfer-progress \ - --batch-mode \ - --file pom.xml -s release/m2-settings.xml deploy -Dversion.modifier='' - env: - OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} - OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} + - uses: google-github-actions/release-please-action@v3 + id: release + with: + command: manifest + token: ${{secrets.GITHUB_TOKEN}} + default-branch: main + + # These steps are only run if this was a merged release-please PR + - name: checkout + if: ${{ steps.release.outputs.releases_created }} + uses: actions/checkout@v3 + - name: Set up JDK 8 + if: ${{ steps.release.outputs.releases_created }} + uses: actions/setup-java@v3 + with: + java-version: '8' + distribution: 'temurin' + cache: maven + server-id: ossrh + server-username: ${{ secrets.OSSRH_USERNAME }} + server-password: ${{ secrets.OSSRH_PASSWORD }} + + - name: Configure GPG Key + if: ${{ steps.release.outputs.releases_created }} + run: | + echo -n "$GPG_SIGNING_KEY" | base64 --decode | gpg --import + env: + GPG_SIGNING_KEY: ${{ secrets.GPG_SIGNING_KEY }} + + - name: Deploy + if: ${{ steps.release.outputs.releases_created }} + run: | + mvn --batch-mode \ + --settings release/m2-settings.xml clean deploy + env: + OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} + OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..5893173a6 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "test-harness"] + path = test-harness + url = https://github.com/open-feature/test-harness diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 000000000..9a316268a --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1 @@ +{".":"0.2.2"} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..f025cfa89 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +## [0.2.2](https://github.com/open-feature/java-sdk/compare/dev.openfeature.javasdk-v0.2.1...dev.openfeature.javasdk-v0.2.2) (2022-09-20) + + +### Features + +* Add asObjectMap to get the EvaluationContext as Map ([#75](https://github.com/open-feature/java-sdk/issues/75)) ([2eec1a5](https://github.com/open-feature/java-sdk/commit/2eec1a5519b9efab7d7f9dc8b1cbd84d9218368b)) + +## [0.2.1](https://github.com/open-feature/java-sdk/compare/dev.openfeature.javasdk-v0.2.0...dev.openfeature.javasdk-v0.2.1) (2022-09-13) + + +### Bug Fixes + +* isList check in Value checks type of list ([#70](https://github.com/open-feature/java-sdk/issues/70)) ([81ab071](https://github.com/open-feature/java-sdk/commit/81ab0710ea56af65eb65c7f95832b8f58c559a51)) + +## [0.2.0](https://github.com/open-feature/java-sdk/compare/dev.openfeature.javasdk-v0.1.1...dev.openfeature.javasdk-v0.2.0) (2022-09-13) + + +### ⚠ BREAKING CHANGES + +* use value for object resolver +* use instant not zoneddatetime + +### Features + +* add object to value wrapper ([0152a1e](https://github.com/open-feature/java-sdk/commit/0152a1eef93ea1b5253ddae78718a9805c98aaf7)) +* use instant not zoneddatetime ([3e62414](https://github.com/open-feature/java-sdk/commit/3e6241422266825f267043e4acd116803c4939b0)) +* use value for object resolver ([5d26247](https://github.com/open-feature/java-sdk/commit/5d262470e8ec47d2af35f0aabe55e8c969e992ac)) diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..07c3c7b09 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +@open-feature/java-maintainers diff --git a/README.md b/README.md index 1458971bf..b32327107 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Maven Central](https://maven-badges.herokuapp.com/maven-central/dev.openfeature/javasdk/badge.svg)](https://maven-badges.herokuapp.com/maven-central/dev.openfeature/javasdk) [![javadoc](https://javadoc.io/badge2/dev.openfeature/javasdk/javadoc.svg)](https://javadoc.io/doc/dev.openfeature/javasdk) [![Project Status: WIP – Initial development is in progress, but there has not yet been a stable, usable release suitable for the public.](https://www.repostatus.org/badges/latest/wip.svg)](https://www.repostatus.org/#wip) -[![Specification](https://img.shields.io/static/v1?label=Specification&message=v0.4.0&color=yellow)](https://github.com/open-feature/spec/tree/v0.4.0) +[![Specification](https://img.shields.io/static/v1?label=Specification&message=v0.5.0&color=yellow)](https://github.com/open-feature/spec/tree/v0.5.0) [![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) @@ -61,13 +61,15 @@ class MyClass { ### Add it to your build #### Maven + ```xml dev.openfeature javasdk - 0.1.0 + 0.2.2 ``` + If you would like snapshot builds, this is the relevant repository information: @@ -85,11 +87,13 @@ If you would like snapshot builds, this is the relevant repository information: ``` #### Gradle + ```groovy dependencies { - implementation 'dev.openfeature:javasdk:0.1.0' + implementation 'dev.openfeature:javasdk:0.2.2' } ``` + ### 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. @@ -108,6 +112,16 @@ We hold regular meetings which you can see [here](https://github.com/open-featur We are also present on the `#openfeature` channel in the [CNCF slack](https://slack.cncf.io/). +## Developing + +### Integration tests + +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 `docker run -p 8013:8013 ghcr.io/open-feature/flagd-testbed:latest` and then run `mvn test -P integration-test`. + +## Releasing + +See [releasing](./docs/release.md). + ## Contributors Thanks so much to our contributors. diff --git a/checkstyle.xml b/checkstyle.xml index 9e524577a..a52e1bf7d 100644 --- a/checkstyle.xml +++ b/checkstyle.xml @@ -280,7 +280,7 @@ value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF, VARIABLE_DEF"/> - + diff --git a/docs/release.md b/docs/release.md index 8ab32ddca..f79cc28da 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,28 +1,11 @@ -This is a simple checklist when performing a release. +# Releases -1. Ensure we're up-to-date with the current spec via `./spec_finder.py --diff-output --refresh-spec` -2. Update `README.md` with the current spec that we're up to -3. Update `README.md` to keep the install instructions on the right version -4. Update `` in pom.xml -5. Run `mvn verify` to ensure it all works. -6. Commit the results as the new version. +This repo uses _Release Please_ to release packages. Release Please sets up a running PR that tracks all changes in the library, and maintains the versions according to [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/), generated when [PRs are merged](https://github.com/amannn/action-semantic-pull-request), based on the PR title. The semantics of the PR title are enforced by the `lint-pr.yml` workflow. When Release Please's running PR is merged, a new release is created, and the associated artifacts are published. -Then in GitHub, trigger a release. +## Customization of changelog and release notes. -1. Go to [github releases](https://github.com/open-feature/java-sdk/releases/new) -2. Name a tag w/ the version you want to release (e.g. `0.1.0`) -3. Click the generate release notes button and write some text about what actually changed. -4. Submit -5. Validate the action which builds the result happened correctly. +If you'd like to add custom content to a release, you can do this by editing the content in a Release Please PR's description. This content will be added to the notes for that release. If you'd like to add content to the changelog, simply push updates to the changelog in the Release Please PR. -If something went wrong above, here's how you reset. -1. Save the release notes you wrote. -2. Delete the release. -3. Delete the tag with `git push --delete origin 0.1.0` where 0.1.0 is your tag name. +## Configuration -```shell -gh release delete 0.1.0 -y -git push --delete origin 0.1.0 - -gh release create 0.1.0 -F /tmp/010-release.md -t 0.1.0 -``` \ No newline at end of file +The `release-please-config.json` defines the release please configuration. See schema [here](https://github.com/googleapis/release-please/blob/main/schemas/config.json) to understand all the options. We use the "simple" release strategy and annotate the POM with an element to help release please find the correct XML entity to update (the version element). Release Please stores it's understanding of the current version in the `version.txt` file. \ No newline at end of file diff --git a/pom.xml b/pom.xml index a712c096e..7d9cae27b 100644 --- a/pom.xml +++ b/pom.xml @@ -4,15 +4,15 @@ dev.openfeature javasdk - 0.1.0${version.modifier} + 0.2.2 - - -SNAPSHOT UTF-8 1.8 ${maven.compiler.source} - 5.8.1 + 5.9.1 + + **/integration/*.java OpenFeature Java SDK @@ -48,12 +48,11 @@ provided - com.github.spotbugs spotbugs - 4.7.1 + 4.7.2 compile @@ -67,7 +66,7 @@ org.mockito mockito-core - 4.6.1 + 4.8.0 test @@ -98,29 +97,82 @@ ${junit.jupiter.version} test + org.junit.jupiter junit-jupiter-api ${junit.jupiter.version} test + org.junit.jupiter junit-jupiter-params ${junit.jupiter.version} test + org.junit.platform junit-platform-suite - 1.8.1 + 1.9.1 + test + + + + io.cucumber + cucumber-java + test + + + + io.cucumber + cucumber-junit-platform-engine + test + + + + com.google.guava + guava + 31.1-jre + test + + + + dev.openfeature.contrib.providers + flagd + + 0.3.2 test + + + + + + io.cucumber + cucumber-bom + 7.8.0 + pom + import + + + + org.junit + junit-bom + 5.9.1 + pom + import + + + + + maven-dependency-plugin 3.3.0 @@ -139,16 +191,21 @@ org.junit* + com.google.guava* + io.cucumber* + org.junit* com.google.code.findbugs* com.github.spotbugs* uk.org.lidalia:lidalia-slf4j-ext:* + maven-compiler-plugin - 3.8.1 + 3.10.1 + org.apache.maven.plugins maven-surefire-plugin @@ -157,6 +214,10 @@ ${surefireArgLine} + + + ${testExclusions} + @@ -226,7 +287,7 @@ - + @@ -235,7 +296,7 @@ org.sonatype.plugins nexus-staging-maven-plugin - 1.6.7 + 1.6.13 true ossrh @@ -249,7 +310,7 @@ org.apache.maven.plugins maven-source-plugin - 2.2.1 + 3.2.1 attach-sources @@ -259,21 +320,15 @@ + org.apache.maven.plugins maven-javadoc-plugin - 3.4.0 + 3.4.1 true - - vet-javadoc - validate - - jar - - attach-javadocs @@ -285,24 +340,26 @@ - org.apache.maven.plugins maven-gpg-plugin - - - --pinentry-mode - loopback - - - 1.5 + 3.0.1 + + + sign-artifacts + install + + sign + + + org.apache.maven.plugins maven-pmd-plugin - 3.13.0 + 3.19.0 run-pmd @@ -317,7 +374,7 @@ com.github.spotbugs spotbugs-maven-plugin - 4.7.0.0 + 4.7.2.0 spotbugs-exclusions.xml @@ -333,7 +390,7 @@ com.github.spotbugs spotbugs - 4.7.1 + 4.7.2 @@ -350,7 +407,7 @@ org.apache.maven.plugins maven-checkstyle-plugin - 3.1.2 + 3.2.0 checkstyle.xml UTF-8 @@ -362,7 +419,7 @@ com.puppycrawl.tools checkstyle - 8.31 + 8.45.1 @@ -379,6 +436,60 @@ + + + + integration-test + + + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + update-test-harness-submodule + validate + + exec + + + + git + + submodule + update + --init + --recursive + + + + + copy-gherkin-tests + validate + + exec + + + + cp + + test-harness/features/evaluation.feature + src/test/resources/features/ + + + + + + + + + @@ -387,5 +498,4 @@ - diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 000000000..bf0d675bb --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,16 @@ +{ + "bootstrap-sha": "c701a6c4ebbe1170a25ca7636a31508b9628831c", + "packages": { + ".": { + "package-name": "dev.openfeature.javasdk", + "release-type": "simple", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [ + "pom.xml", + "README.md" + ] + } + } +} \ No newline at end of file diff --git a/renovate.json b/renovate.json new file mode 100644 index 000000000..39a2b6e9a --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base" + ] +} diff --git a/src/main/java/dev/openfeature/javasdk/BaseEvaluation.java b/src/main/java/dev/openfeature/javasdk/BaseEvaluation.java index d07da8540..f0524e065 100644 --- a/src/main/java/dev/openfeature/javasdk/BaseEvaluation.java +++ b/src/main/java/dev/openfeature/javasdk/BaseEvaluation.java @@ -21,11 +21,18 @@ public interface BaseEvaluation { * Describes how we came to the value that we're returning. * @return {Reason} */ - Reason getReason(); + String getReason(); /** * The error code, if applicable. Should only be set when the Reason is ERROR. * @return {ErrorCode} */ - String getErrorCode(); + ErrorCode getErrorCode(); + + /** + * The error message (usually from exception.getMessage()), if applicable. + * Should only be set when the Reason is ERROR. + * @return {String} + */ + String getErrorMessage(); } diff --git a/src/main/java/dev/openfeature/javasdk/ErrorCode.java b/src/main/java/dev/openfeature/javasdk/ErrorCode.java index 9b0c74d71..7b54ce564 100644 --- a/src/main/java/dev/openfeature/javasdk/ErrorCode.java +++ b/src/main/java/dev/openfeature/javasdk/ErrorCode.java @@ -1,5 +1,5 @@ package dev.openfeature.javasdk; public enum ErrorCode { - PROVIDER_NOT_READY, FLAG_NOT_FOUND, PARSE_ERROR, TYPE_MISMATCH, GENERAL + PROVIDER_NOT_READY, FLAG_NOT_FOUND, PARSE_ERROR, TYPE_MISMATCH, TARGETING_KEY_MISSING, INVALID_CONTEXT, GENERAL } diff --git a/src/main/java/dev/openfeature/javasdk/EvaluationContext.java b/src/main/java/dev/openfeature/javasdk/EvaluationContext.java index fe9740b8f..f7f11aded 100644 --- a/src/main/java/dev/openfeature/javasdk/EvaluationContext.java +++ b/src/main/java/dev/openfeature/javasdk/EvaluationContext.java @@ -1,6 +1,6 @@ package dev.openfeature.javasdk; -import java.time.ZonedDateTime; +import java.time.Instant; import java.util.List; import lombok.Getter; @@ -76,7 +76,7 @@ public EvaluationContext add(String key, Double value) { return this; } - public EvaluationContext add(String key, ZonedDateTime value) { + public EvaluationContext add(String key, Instant value) { this.structure.add(key, value); return this; } @@ -123,7 +123,7 @@ public Structure add(String ignoredKey, Structure ignoredValue) { return null; } - public Structure add(String ignoredKey, ZonedDateTime ignoredValue) { + public Structure add(String ignoredKey, Instant ignoredValue) { return null; } } diff --git a/src/main/java/dev/openfeature/javasdk/FeatureProvider.java b/src/main/java/dev/openfeature/javasdk/FeatureProvider.java index f67942143..0938185ed 100644 --- a/src/main/java/dev/openfeature/javasdk/FeatureProvider.java +++ b/src/main/java/dev/openfeature/javasdk/FeatureProvider.java @@ -21,5 +21,5 @@ default List getProviderHooks() { ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx); - ProviderEvaluation getObjectEvaluation(String key, Structure defaultValue, EvaluationContext ctx); + ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx); } diff --git a/src/main/java/dev/openfeature/javasdk/Features.java b/src/main/java/dev/openfeature/javasdk/Features.java index 61a1d3905..79bd403f3 100644 --- a/src/main/java/dev/openfeature/javasdk/Features.java +++ b/src/main/java/dev/openfeature/javasdk/Features.java @@ -57,19 +57,19 @@ FlagEvaluationDetails getIntegerDetails(String key, Integer defaultValu FlagEvaluationDetails getDoubleDetails(String key, Double defaultValue, EvaluationContext ctx, FlagEvaluationOptions options); - Structure getObjectValue(String key, Structure defaultValue); + Value getObjectValue(String key, Value defaultValue); - Structure getObjectValue(String key, Structure defaultValue, EvaluationContext ctx); + Value getObjectValue(String key, Value defaultValue, EvaluationContext ctx); - Structure getObjectValue(String key, Structure defaultValue, EvaluationContext ctx, + Value getObjectValue(String key, Value defaultValue, EvaluationContext ctx, FlagEvaluationOptions options); - FlagEvaluationDetails getObjectDetails(String key, Structure defaultValue); + FlagEvaluationDetails getObjectDetails(String key, Value defaultValue); - FlagEvaluationDetails getObjectDetails(String key, Structure defaultValue, + FlagEvaluationDetails getObjectDetails(String key, Value defaultValue, EvaluationContext ctx); - FlagEvaluationDetails getObjectDetails(String key, Structure defaultValue, + FlagEvaluationDetails getObjectDetails(String key, Value defaultValue, EvaluationContext ctx, FlagEvaluationOptions options); } diff --git a/src/main/java/dev/openfeature/javasdk/FlagEvaluationDetails.java b/src/main/java/dev/openfeature/javasdk/FlagEvaluationDetails.java index 2c5c25ec1..0440cf5b5 100644 --- a/src/main/java/dev/openfeature/javasdk/FlagEvaluationDetails.java +++ b/src/main/java/dev/openfeature/javasdk/FlagEvaluationDetails.java @@ -14,8 +14,9 @@ public class FlagEvaluationDetails implements BaseEvaluation { private String flagKey; private T value; @Nullable private String variant; - private Reason reason; - @Nullable private String errorCode; + @Nullable private String reason; + private ErrorCode errorCode; + @Nullable private String errorMessage; /** * Generate detail payload from the provider response. diff --git a/src/main/java/dev/openfeature/javasdk/NoOpProvider.java b/src/main/java/dev/openfeature/javasdk/NoOpProvider.java index 35818a7ae..ef7dccd74 100644 --- a/src/main/java/dev/openfeature/javasdk/NoOpProvider.java +++ b/src/main/java/dev/openfeature/javasdk/NoOpProvider.java @@ -25,7 +25,7 @@ public ProviderEvaluation getBooleanEvaluation(String key, Boolean defa return ProviderEvaluation.builder() .value(defaultValue) .variant(PASSED_IN_DEFAULT) - .reason(Reason.DEFAULT) + .reason(Reason.DEFAULT.toString()) .build(); } @@ -34,7 +34,7 @@ public ProviderEvaluation getStringEvaluation(String key, String default return ProviderEvaluation.builder() .value(defaultValue) .variant(PASSED_IN_DEFAULT) - .reason(Reason.DEFAULT) + .reason(Reason.DEFAULT.toString()) .build(); } @@ -43,7 +43,7 @@ public ProviderEvaluation getIntegerEvaluation(String key, Integer defa return ProviderEvaluation.builder() .value(defaultValue) .variant(PASSED_IN_DEFAULT) - .reason(Reason.DEFAULT) + .reason(Reason.DEFAULT.toString()) .build(); } @@ -52,17 +52,17 @@ public ProviderEvaluation getDoubleEvaluation(String key, Double default return ProviderEvaluation.builder() .value(defaultValue) .variant(PASSED_IN_DEFAULT) - .reason(Reason.DEFAULT) + .reason(Reason.DEFAULT.toString()) .build(); } @Override - public ProviderEvaluation getObjectEvaluation(String key, Structure defaultValue, + public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext invocationContext) { - return ProviderEvaluation.builder() + return ProviderEvaluation.builder() .value(defaultValue) .variant(PASSED_IN_DEFAULT) - .reason(Reason.DEFAULT) + .reason(Reason.DEFAULT.toString()) .build(); } } diff --git a/src/main/java/dev/openfeature/javasdk/OpenFeatureAPI.java b/src/main/java/dev/openfeature/javasdk/OpenFeatureAPI.java index 5f4c0415a..76bb3cb31 100644 --- a/src/main/java/dev/openfeature/javasdk/OpenFeatureAPI.java +++ b/src/main/java/dev/openfeature/javasdk/OpenFeatureAPI.java @@ -19,7 +19,7 @@ public class OpenFeatureAPI { private FeatureProvider provider; @Getter @Setter - private EvaluationContext ctx; + private EvaluationContext evaluationContext; @Getter private List apiHooks; diff --git a/src/main/java/dev/openfeature/javasdk/OpenFeatureClient.java b/src/main/java/dev/openfeature/javasdk/OpenFeatureClient.java index 4cddd8089..361e8bc39 100644 --- a/src/main/java/dev/openfeature/javasdk/OpenFeatureClient.java +++ b/src/main/java/dev/openfeature/javasdk/OpenFeatureClient.java @@ -7,6 +7,7 @@ import java.util.Map; import dev.openfeature.javasdk.exceptions.GeneralError; +import dev.openfeature.javasdk.exceptions.OpenFeatureError; import dev.openfeature.javasdk.internal.ObjectUtils; import lombok.Getter; import lombok.Setter; @@ -78,7 +79,7 @@ private FlagEvaluationDetails evaluateFlag(FlagValueType type, String key // merge of: API.context, client.context, invocation.context EvaluationContext mergedCtx = EvaluationContext.merge( EvaluationContext.merge( - openfeatureApi.getCtx(), + openfeatureApi.getEvaluationContext(), this.getEvaluationContext() ), invocationCtx @@ -94,9 +95,14 @@ private FlagEvaluationDetails evaluateFlag(FlagValueType type, String key if (details == null) { details = FlagEvaluationDetails.builder().build(); } + if (e instanceof OpenFeatureError) { + details.setErrorCode(((OpenFeatureError)e).getErrorCode()); + } else { + details.setErrorCode(ErrorCode.GENERAL); + } + details.setErrorMessage(e.getMessage()); details.setValue(defaultValue); - details.setReason(Reason.ERROR); - details.setErrorCode(e.getMessage()); + details.setReason(Reason.ERROR.toString()); hookSupport.errorHooks(type, hookCtx, e, mergedHooks, hints); } finally { hookSupport.afterAllHooks(type, hookCtx, mergedHooks, hints); @@ -122,7 +128,7 @@ private ProviderEvaluation createProviderEvaluation( case DOUBLE: return provider.getDoubleEvaluation(key, (Double) defaultValue, invocationContext); case OBJECT: - return provider.getObjectEvaluation(key, (Structure) defaultValue, invocationContext); + return provider.getObjectEvaluation(key, (Value) defaultValue, invocationContext); default: throw new GeneralError("Unknown flag type"); } @@ -257,34 +263,34 @@ public FlagEvaluationDetails getDoubleDetails(String key, Double default } @Override - public Structure getObjectValue(String key, Structure defaultValue) { + public Value getObjectValue(String key, Value defaultValue) { return getObjectDetails(key, defaultValue).getValue(); } @Override - public Structure getObjectValue(String key, Structure defaultValue, EvaluationContext ctx) { + public Value getObjectValue(String key, Value defaultValue, EvaluationContext ctx) { return getObjectDetails(key, defaultValue, ctx).getValue(); } @Override - public Structure getObjectValue(String key, Structure defaultValue, EvaluationContext ctx, + public Value getObjectValue(String key, Value defaultValue, EvaluationContext ctx, FlagEvaluationOptions options) { return getObjectDetails(key, defaultValue, ctx, options).getValue(); } @Override - public FlagEvaluationDetails getObjectDetails(String key, Structure defaultValue) { + public FlagEvaluationDetails getObjectDetails(String key, Value defaultValue) { return getObjectDetails(key, defaultValue, null); } @Override - public FlagEvaluationDetails getObjectDetails(String key, Structure defaultValue, + public FlagEvaluationDetails getObjectDetails(String key, Value defaultValue, EvaluationContext ctx) { return getObjectDetails(key, defaultValue, ctx, FlagEvaluationOptions.builder().build()); } @Override - public FlagEvaluationDetails getObjectDetails(String key, Structure defaultValue, EvaluationContext ctx, + public FlagEvaluationDetails getObjectDetails(String key, Value defaultValue, EvaluationContext ctx, FlagEvaluationOptions options) { return this.evaluateFlag(FlagValueType.OBJECT, key, defaultValue, ctx, options); } diff --git a/src/main/java/dev/openfeature/javasdk/ProviderEvaluation.java b/src/main/java/dev/openfeature/javasdk/ProviderEvaluation.java index e2d884cd8..df9391347 100644 --- a/src/main/java/dev/openfeature/javasdk/ProviderEvaluation.java +++ b/src/main/java/dev/openfeature/javasdk/ProviderEvaluation.java @@ -9,6 +9,7 @@ public class ProviderEvaluation implements BaseEvaluation { T value; @Nullable String variant; - Reason reason; - @Nullable String errorCode; + @Nullable private String reason; + ErrorCode errorCode; + @Nullable private String errorMessage; } diff --git a/src/main/java/dev/openfeature/javasdk/Structure.java b/src/main/java/dev/openfeature/javasdk/Structure.java index 05a665522..87491e66c 100644 --- a/src/main/java/dev/openfeature/javasdk/Structure.java +++ b/src/main/java/dev/openfeature/javasdk/Structure.java @@ -1,11 +1,10 @@ package dev.openfeature.javasdk; -import java.time.ZonedDateTime; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; +import dev.openfeature.javasdk.exceptions.ValueNotConvertableError; import lombok.EqualsAndHashCode; import lombok.ToString; @@ -41,6 +40,11 @@ public Value getValue(String key) { } // adders + public Structure add(String key, Value value) { + attributes.put(key, value); + return this; + } + public Structure add(String key, Boolean value) { attributes.put(key, new Value(value)); return this; @@ -61,14 +65,14 @@ public Structure add(String key, Double value) { return this; } - /** + /** * Add date-time relevant key. - * + * * @param key feature key * @param value date-time value * @return Structure */ - public Structure add(String key, ZonedDateTime value) { + public Structure add(String key, Instant value) { attributes.put(key, new Value(value)); return this; } @@ -85,10 +89,73 @@ public Structure add(String key, List value) { /** * Get all values. - * + * * @return all attributes on the structure */ public Map asMap() { return new HashMap<>(this.attributes); } + + /** + * Get all values, with primitives types. + * + * @return all attributes on the structure into a Map + */ + public Map asObjectMap() { + return attributes + .entrySet() + .stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + e -> convertValue(getValue(e.getKey())) + )); + } + + /** + * convertValue is converting the object type Value in a primitive type. + * @param value - Value object to convert + * @return an Object containing the primitive type. + */ + private Object convertValue(Value value) { + if (value.isBoolean()) { + return value.asBoolean(); + } + + if (value.isNumber()) { + Double valueAsDouble = value.asDouble(); + if (valueAsDouble == Math.floor(valueAsDouble) && !Double.isInfinite(valueAsDouble)) { + return value.asInteger(); + } + return valueAsDouble; + } + + if (value.isString()) { + return value.asString(); + } + + if (value.isInstant()) { + return value.asInstant(); + } + + if (value.isList()) { + return value.asList() + .stream() + .map(this::convertValue) + .collect(Collectors.toList()); + } + + if (value.isStructure()) { + Structure s = value.asStructure(); + return s.asMap() + .keySet() + .stream() + .collect( + Collectors.toMap( + key -> key, + key -> convertValue(s.getValue(key)) + ) + ); + } + throw new ValueNotConvertableError(); + } } diff --git a/src/main/java/dev/openfeature/javasdk/Value.java b/src/main/java/dev/openfeature/javasdk/Value.java index 786ee2956..abadd606f 100644 --- a/src/main/java/dev/openfeature/javasdk/Value.java +++ b/src/main/java/dev/openfeature/javasdk/Value.java @@ -1,15 +1,15 @@ package dev.openfeature.javasdk; -import java.time.ZonedDateTime; +import java.time.Instant; import java.util.List; import lombok.EqualsAndHashCode; import lombok.ToString; /** - * Values server as a return type for provider objects. Providers may deal in protobufs or JSON in the backend and - * have no reasonable way to convert that into a type that users care about (e.g. an instance of `T`). This - * intermediate representation should provide a good medium of exchange. + * Values serve as a generic return type for structure data from providers. + * Providers may deal in JSON, protobuf, XML or some other data-interchange format. + * This intermediate representation provides a good medium of exchange. */ @ToString @EqualsAndHashCode @@ -18,6 +18,30 @@ public class Value { private final Object innerObject; + public Value() { + this.innerObject = null; + } + + /** + * Construct a new Value with an Object. + * @param value to be wrapped. + * @throws InstantiationException if value is not a valid type + * (boolean, string, int, double, list, structure, instant) + */ + public Value(Object value) throws InstantiationException { + // integer is a special case, convert those. + this.innerObject = value instanceof Integer ? ((Integer)value).doubleValue() : value; + if (!this.isNull() + && !this.isBoolean() + && !this.isString() + && !this.isNumber() + && !this.isStructure() + && !this.isList() + && !this.isInstant()) { + throw new InstantiationException("Invalid value type: " + value.getClass()); + } + } + public Value(Value value) { this.innerObject = value.innerObject; } @@ -31,7 +55,7 @@ public Value(String value) { } public Value(Integer value) { - this.innerObject = value; + this.innerObject = value.doubleValue(); } public Value(Double value) { @@ -46,43 +70,43 @@ public Value(List value) { this.innerObject = value; } - public Value(ZonedDateTime value) { + public Value(Instant value) { this.innerObject = value; } /** - * Check if this Value represents a Boolean. + * Check if this Value represents null. * * @return boolean */ - public boolean isBoolean() { - return this.innerObject instanceof Boolean; + public boolean isNull() { + return this.innerObject == null; } /** - * Check if this Value represents a String. + * Check if this Value represents a Boolean. * * @return boolean */ - public boolean isString() { - return this.innerObject instanceof String; + public boolean isBoolean() { + return this.innerObject instanceof Boolean; } /** - * Check if this Value represents an Integer. + * Check if this Value represents a String. * * @return boolean */ - public boolean isInteger() { - return this.innerObject instanceof Integer; + public boolean isString() { + return this.innerObject instanceof String; } /** - * Check if this Value represents a Double. + * Check if this Value represents a numeric value. * * @return boolean */ - public boolean isDouble() { + public boolean isNumber() { return this.innerObject instanceof Double; } @@ -101,16 +125,18 @@ public boolean isStructure() { * @return boolean */ public boolean isList() { - return this.innerObject instanceof List; + return this.innerObject instanceof List + && (((List) this.innerObject).isEmpty() + || ((List) this.innerObject).get(0) instanceof Value); } /** - * Check if this Value represents a ZonedDateTime. + * Check if this Value represents an Instant. * * @return boolean */ - public boolean isZonedDateTime() { - return this.innerObject instanceof ZonedDateTime; + public boolean isInstant() { + return this.innerObject instanceof Instant; } /** @@ -127,6 +153,15 @@ public Boolean asBoolean() { return null; } + /** + * Retrieve the underlying object. + * + * @return Object + */ + public Object asObject() { + return this.innerObject; + } + /** * Retrieve the underlying String value, or null. * @@ -140,24 +175,25 @@ public String asString() { } /** - * Retrieve the underlying Integer value, or null. + * Retrieve the underlying numeric value as an Integer, or null. + * If the value is not an integer, it will be rounded using Math.round(). * * @return Integer */ public Integer asInteger() { - if (this.isInteger()) { - return (Integer)this.innerObject; + if (this.isNumber()) { + return (int)Math.round((Double)this.innerObject); } return null; } /** - * Retrieve the underlying Double value, or null. + * Retrieve the underlying numeric value as a Double, or null. * * @return Double */ public Double asDouble() { - if (this.isDouble()) { + if (this.isNumber()) { return (Double)this.innerObject; } return null; @@ -189,13 +225,13 @@ public List asList() { } /** - * Retrieve the underlying ZonedDateTime value, or null. + * Retrieve the underlying Instant value, or null. * - * @return ZonedDateTime + * @return Instant */ - public ZonedDateTime asZonedDateTime() { - if (this.isZonedDateTime()) { - return (ZonedDateTime)this.innerObject; + public Instant asInstant() { + if (this.isInstant()) { + return (Instant)this.innerObject; } return null; } diff --git a/src/main/java/dev/openfeature/javasdk/exceptions/FlagNotFoundError.java b/src/main/java/dev/openfeature/javasdk/exceptions/FlagNotFoundError.java index 6aa2d7ed1..c91971ae4 100644 --- a/src/main/java/dev/openfeature/javasdk/exceptions/FlagNotFoundError.java +++ b/src/main/java/dev/openfeature/javasdk/exceptions/FlagNotFoundError.java @@ -7,5 +7,5 @@ @StandardException public class FlagNotFoundError extends OpenFeatureError { private static final long serialVersionUID = 1L; - @Getter private final ErrorCode errorCode = ErrorCode.GENERAL; + @Getter private final ErrorCode errorCode = ErrorCode.FLAG_NOT_FOUND; } diff --git a/src/main/java/dev/openfeature/javasdk/exceptions/InvalidContextError.java b/src/main/java/dev/openfeature/javasdk/exceptions/InvalidContextError.java new file mode 100644 index 000000000..e8487054f --- /dev/null +++ b/src/main/java/dev/openfeature/javasdk/exceptions/InvalidContextError.java @@ -0,0 +1,13 @@ +package dev.openfeature.javasdk.exceptions; + +import dev.openfeature.javasdk.ErrorCode; +import lombok.Getter; +import lombok.experimental.StandardException; + +@StandardException +public class InvalidContextError extends OpenFeatureError { + private static final long serialVersionUID = 1L; + + @Getter private final ErrorCode errorCode = ErrorCode.INVALID_CONTEXT; + +} diff --git a/src/main/java/dev/openfeature/javasdk/exceptions/TargetingKeyMissingError.java b/src/main/java/dev/openfeature/javasdk/exceptions/TargetingKeyMissingError.java new file mode 100644 index 000000000..7e914d298 --- /dev/null +++ b/src/main/java/dev/openfeature/javasdk/exceptions/TargetingKeyMissingError.java @@ -0,0 +1,13 @@ +package dev.openfeature.javasdk.exceptions; + +import dev.openfeature.javasdk.ErrorCode; +import lombok.Getter; +import lombok.experimental.StandardException; + +@StandardException +public class TargetingKeyMissingError extends OpenFeatureError { + private static final long serialVersionUID = 1L; + + @Getter private final ErrorCode errorCode = ErrorCode.TARGETING_KEY_MISSING; + +} diff --git a/src/main/java/dev/openfeature/javasdk/exceptions/ValueNotConvertableError.java b/src/main/java/dev/openfeature/javasdk/exceptions/ValueNotConvertableError.java new file mode 100644 index 000000000..1cbff419f --- /dev/null +++ b/src/main/java/dev/openfeature/javasdk/exceptions/ValueNotConvertableError.java @@ -0,0 +1,12 @@ +package dev.openfeature.javasdk.exceptions; + +import dev.openfeature.javasdk.ErrorCode; +import lombok.Getter; +import lombok.experimental.StandardException; + +@StandardException +public class ValueNotConvertableError extends OpenFeatureError { + private static final long serialVersionUID = 1L; + @Getter + private final ErrorCode errorCode = ErrorCode.GENERAL; +} diff --git a/src/test/java/dev/openfeature/javasdk/AlwaysBrokenProvider.java b/src/test/java/dev/openfeature/javasdk/AlwaysBrokenProvider.java index cd172afc7..044f3ce7b 100644 --- a/src/test/java/dev/openfeature/javasdk/AlwaysBrokenProvider.java +++ b/src/test/java/dev/openfeature/javasdk/AlwaysBrokenProvider.java @@ -1,39 +1,41 @@ package dev.openfeature.javasdk; +import dev.openfeature.javasdk.exceptions.FlagNotFoundError; public class AlwaysBrokenProvider implements FeatureProvider { + @Override public Metadata getMetadata() { return new Metadata() { @Override public String getName() { - throw new NotImplementedException("BORK"); + throw new FlagNotFoundError(TestConstants.BROKEN_MESSAGE); } }; } @Override public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { - throw new NotImplementedException("BORK"); + throw new FlagNotFoundError(TestConstants.BROKEN_MESSAGE); } @Override public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { - throw new NotImplementedException("BORK"); + throw new FlagNotFoundError(TestConstants.BROKEN_MESSAGE); } @Override public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { - throw new NotImplementedException("BORK"); + throw new FlagNotFoundError(TestConstants.BROKEN_MESSAGE); } @Override public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { - throw new NotImplementedException("BORK"); + throw new FlagNotFoundError(TestConstants.BROKEN_MESSAGE); } @Override - public ProviderEvaluation getObjectEvaluation(String key, Structure defaultValue, EvaluationContext invocationContext) { - throw new NotImplementedException("BORK"); + public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext invocationContext) { + throw new FlagNotFoundError(TestConstants.BROKEN_MESSAGE); } } diff --git a/src/test/java/dev/openfeature/javasdk/DeveloperExperienceTest.java b/src/test/java/dev/openfeature/javasdk/DeveloperExperienceTest.java index 3e238e318..abd1d95a6 100644 --- a/src/test/java/dev/openfeature/javasdk/DeveloperExperienceTest.java +++ b/src/test/java/dev/openfeature/javasdk/DeveloperExperienceTest.java @@ -85,8 +85,9 @@ class DeveloperExperienceTest implements HookFixtures { api.setProvider(new AlwaysBrokenProvider()); Client client = api.getClient(); FlagEvaluationDetails retval = client.getBooleanDetails(flagKey, false); - assertEquals("BORK", retval.getErrorCode()); - assertEquals(Reason.ERROR, retval.getReason()); + assertEquals(ErrorCode.FLAG_NOT_FOUND, retval.getErrorCode()); + assertEquals(TestConstants.BROKEN_MESSAGE, retval.getErrorMessage()); + assertEquals(Reason.ERROR.toString(), retval.getReason()); assertFalse(retval.getValue()); } } diff --git a/src/test/java/dev/openfeature/javasdk/DoSomethingProvider.java b/src/test/java/dev/openfeature/javasdk/DoSomethingProvider.java index 91600e09b..1fd6d9dc2 100644 --- a/src/test/java/dev/openfeature/javasdk/DoSomethingProvider.java +++ b/src/test/java/dev/openfeature/javasdk/DoSomethingProvider.java @@ -44,9 +44,9 @@ public ProviderEvaluation getDoubleEvaluation(String key, Double default } @Override - public ProviderEvaluation getObjectEvaluation(String key, Structure defaultValue, EvaluationContext invocationContext) { + public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext invocationContext) { savedContext = invocationContext; - return ProviderEvaluation.builder() + return ProviderEvaluation.builder() .value(null) .build(); } diff --git a/src/test/java/dev/openfeature/javasdk/EvalContextTest.java b/src/test/java/dev/openfeature/javasdk/EvalContextTest.java index 681a7e09c..3cb207881 100644 --- a/src/test/java/dev/openfeature/javasdk/EvalContextTest.java +++ b/src/test/java/dev/openfeature/javasdk/EvalContextTest.java @@ -2,8 +2,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; -import java.time.ZonedDateTime; +import java.time.Instant; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -34,9 +35,9 @@ public class EvalContextTest { ec.add("int", 4); assertEquals(4, ec.getValue("int").asInteger()); - ZonedDateTime dt = ZonedDateTime.now(); + Instant dt = Instant.now(); ec.add("dt", dt); - assertEquals(dt, ec.getValue("dt").asZonedDateTime()); + assertEquals(dt, ec.getValue("dt").asInstant()); } @Specification(number="3.1.2", text="The evaluation context MUST support the inclusion of " + @@ -72,7 +73,7 @@ public class EvalContextTest { ec.add("int", 4); ec.add("int2", 2); - ZonedDateTime dt = ZonedDateTime.now(); + Instant dt = Instant.now(); ec.add("dt", dt); ec.add("obj", new Structure().add("val1", 1).add("val2", "2")); @@ -121,14 +122,14 @@ public class EvalContextTest { .add("Double", (Double)null) .add("Structure", (Structure)null) .add("List", (List)null) - .add("ZonedDateTime", (ZonedDateTime)null); + .add("Instant", (Instant)null); assertEquals(6, ec.asMap().size()); assertEquals(null, ec.getValue("Boolean").asBoolean()); assertEquals(null, ec.getValue("String").asString()); assertEquals(null, ec.getValue("Double").asDouble()); assertEquals(null, ec.getValue("Structure").asStructure()); assertEquals(null, ec.getValue("List").asList()); - assertEquals(null, ec.getValue("ZonedDateTime").asString()); + assertEquals(null, ec.getValue("Instant").asString()); } @Test void merge_targeting_key() { @@ -148,4 +149,55 @@ public class EvalContextTest { ctxMerged = EvaluationContext.merge(ctx1, ctx2); assertEquals(key1, ctxMerged.getTargetingKey()); } + + @Test void asObjectMap() { + String key1 = "key1"; + EvaluationContext ctx = new EvaluationContext(key1); + ctx.add("stringItem", "stringValue"); + ctx.add("boolItem", false); + ctx.add("integerItem", 1); + ctx.add("doubleItem", 1.2); + ctx.add("instantItem", Instant.ofEpochSecond(1663331342)); + List listItem = new ArrayList<>(); + listItem.add(new Value("item1")); + listItem.add(new Value("item2")); + ctx.add("listItem", listItem); + List listItem2 = new ArrayList<>(); + listItem2.add(new Value(true)); + listItem2.add(new Value(false)); + ctx.add("listItem2", listItem2); + Map structureValue = new HashMap<>(); + structureValue.put("structStringItem", new Value("stringValue")); + structureValue.put("structBoolItem", new Value(false)); + structureValue.put("structIntegerItem", new Value(1)); + structureValue.put("structDoubleItem", new Value(1.2)); + structureValue.put("structInstantItem", new Value(Instant.ofEpochSecond(1663331342))); + Structure structure = new Structure(structureValue); + ctx.add("structureItem", structure); + + + Map want = new HashMap<>(); + want.put("stringItem", "stringValue"); + want.put("boolItem", false); + want.put("integerItem", 1); + want.put("doubleItem", 1.2); + want.put("instantItem", Instant.ofEpochSecond(1663331342)); + List wantListItem = new ArrayList<>(); + wantListItem.add("item1"); + wantListItem.add("item2"); + want.put("listItem", wantListItem); + List wantListItem2 = new ArrayList<>(); + wantListItem2.add(true); + wantListItem2.add(false); + want.put("listItem2", wantListItem2); + Map wantStructureValue = new HashMap<>(); + wantStructureValue.put("structStringItem", "stringValue"); + wantStructureValue.put("structBoolItem", false); + wantStructureValue.put("structIntegerItem", 1); + wantStructureValue.put("structDoubleItem", 1.2); + wantStructureValue.put("structInstantItem", Instant.ofEpochSecond(1663331342)); + want.put("structureItem",wantStructureValue); + + assertEquals(want,ctx.asObjectMap()); + } } diff --git a/src/test/java/dev/openfeature/javasdk/FlagEvaluationSpecTest.java b/src/test/java/dev/openfeature/javasdk/FlagEvaluationSpecTest.java index be3e48305..f2ef69df7 100644 --- a/src/test/java/dev/openfeature/javasdk/FlagEvaluationSpecTest.java +++ b/src/test/java/dev/openfeature/javasdk/FlagEvaluationSpecTest.java @@ -33,7 +33,7 @@ private Client _client() { @AfterEach void reset_ctx() { OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setCtx(null); + api.setEvaluationContext(null); } @Specification(number="1.1.1", text="The API, and any state it maintains SHOULD exist as a global singleton, even in cases wherein multiple versions of the API are present at runtime.") @@ -115,9 +115,9 @@ private Client _client() { assertEquals(40.0, c.getDoubleValue(key, .4, new EvaluationContext())); assertEquals(40.0, c.getDoubleValue(key, .4, new EvaluationContext(), FlagEvaluationOptions.builder().build())); - assertEquals(null, c.getObjectValue(key, new Structure())); - assertEquals(null, c.getObjectValue(key, new Structure(), new EvaluationContext())); - assertEquals(null, c.getObjectValue(key, new Structure(), new EvaluationContext(), FlagEvaluationOptions.builder().build())); + assertEquals(null, c.getObjectValue(key, new Value())); + assertEquals(null, c.getObjectValue(key, new Value(), new EvaluationContext())); + assertEquals(null, c.getObjectValue(key, new Value(), new EvaluationContext(), FlagEvaluationOptions.builder().build())); } @Specification(number="1.4.1", text="The client MUST provide methods for detailed flag value evaluation with parameters flag key (string, required), default value (boolean | number | string | structure, required), evaluation context (optional), and evaluation options (optional), which returns an evaluation details structure.") @@ -183,14 +183,16 @@ private Client _client() { } @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.") - @Specification(number="1.4.7", text="In cases of abnormal execution, the evaluation details structure's error code field MUST contain a string identifying an error occurred during flag evaluation and the nature of the error.") + @Specification(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()); Client c = api.getClient(); assertFalse(c.getBooleanValue("key", false)); FlagEvaluationDetails details = c.getBooleanDetails("key", false); - assertEquals("BORK", details.getErrorCode()); + assertEquals(ErrorCode.FLAG_NOT_FOUND, details.getErrorCode()); + assertEquals(TestConstants.BROKEN_MESSAGE, details.getErrorMessage()); } @Specification(number="1.4.10", text="In the case of abnormal execution, the client SHOULD log an informative error message.") @@ -200,8 +202,8 @@ private Client _client() { api.setProvider(new AlwaysBrokenProvider()); Client c = api.getClient(); FlagEvaluationDetails result = c.getBooleanDetails("test", false); - assertEquals(Reason.ERROR, result.getReason()); - assertThat(TEST_LOGGER.getLoggingEvents()).contains(LoggingEvent.error("Unable to correctly evaluate flag with key {} due to exception {}", "test", "BORK")); + assertEquals(Reason.ERROR.toString(), result.getReason()); + assertThat(TEST_LOGGER.getLoggingEvents()).contains(LoggingEvent.error("Unable to correctly evaluate flag with key {} due to exception {}", "test", TestConstants.BROKEN_MESSAGE)); } @Specification(number="1.2.2", text="The client interface MUST define a metadata member or accessor, containing an immutable name field or accessor of type string, which corresponds to the name value supplied during client creation.") @@ -221,7 +223,7 @@ private Client _client() { api.setProvider(new AlwaysBrokenProvider()); Client c = api.getClient(); FlagEvaluationDetails result = c.getBooleanDetails("test", false); - assertEquals(Reason.ERROR, result.getReason()); + assertEquals(Reason.ERROR.toString(), result.getReason()); } @Specification(number="3.2.1", text="The API, Client and invocation MUST have a method for supplying evaluation context.") @@ -235,7 +237,7 @@ private Client _client() { apiCtx.add("common", "1"); apiCtx.add("common2", "1"); apiCtx.add("api", "2"); - api.setCtx(apiCtx); + api.setEvaluationContext(apiCtx); Client c = api.getClient(); EvaluationContext clientCtx = new EvaluationContext(); diff --git a/src/test/java/dev/openfeature/javasdk/NoOpProviderTest.java b/src/test/java/dev/openfeature/javasdk/NoOpProviderTest.java index 4a45860c8..0853a2956 100644 --- a/src/test/java/dev/openfeature/javasdk/NoOpProviderTest.java +++ b/src/test/java/dev/openfeature/javasdk/NoOpProviderTest.java @@ -30,10 +30,10 @@ public class NoOpProviderTest { assertEquals(0.4, eval.getValue()); } - @Test void structure() { + @Test void value() { NoOpProvider p = new NoOpProvider(); - Structure s = new Structure(); - ProviderEvaluation eval = p.getObjectEvaluation("key", s, null); + Value s = new Value(); + ProviderEvaluation eval = p.getObjectEvaluation("key", s, null); assertEquals(s, eval.getValue()); } } diff --git a/src/test/java/dev/openfeature/javasdk/ProviderSpecTest.java b/src/test/java/dev/openfeature/javasdk/ProviderSpecTest.java index 9151badf3..846d0d482 100644 --- a/src/test/java/dev/openfeature/javasdk/ProviderSpecTest.java +++ b/src/test/java/dev/openfeature/javasdk/ProviderSpecTest.java @@ -36,16 +36,15 @@ public class ProviderSpecTest { ProviderEvaluation boolean_result = p.getBooleanEvaluation("key", false, new EvaluationContext()); assertNotNull(boolean_result.getValue()); - ProviderEvaluation object_result = p.getObjectEvaluation("key", new Structure(), new EvaluationContext()); + ProviderEvaluation object_result = p.getObjectEvaluation("key", new Value(), new EvaluationContext()); assertNotNull(object_result.getValue()); } - @Specification(number="2.6", text="The provider SHOULD populate the flag resolution structure's " + - "reason field with a string indicating the semantic reason for the returned flag value.") + @Specification(number="2.6", text="The `provider` SHOULD populate the `flag resolution` structure's `reason` field with `\"DEFAULT\",` `\"TARGETING_MATCH\"`, `\"SPLIT\"`, `\"DISABLED\"`, `\"UNKNOWN\"`, `\"ERROR\"` or some other string indicating the semantic reason for the returned flag value.") @Test void has_reason() { ProviderEvaluation result = p.getBooleanEvaluation("key", false, new EvaluationContext()); - assertEquals(Reason.DEFAULT, result.getReason()); + assertEquals(Reason.DEFAULT.toString(), result.getReason()); } @Specification(number="2.7", text="In cases of normal execution, the provider MUST NOT populate " + @@ -55,9 +54,9 @@ public class ProviderSpecTest { assertNull(result.getErrorCode()); } - @Specification(number="2.8", text="In cases of abnormal execution, the provider MUST indicate an " + - "error using the idioms of the implementation language, with an associated error code having possible " + - "values PROVIDER_NOT_READY, FLAG_NOT_FOUND, PARSE_ERROR, TYPE_MISMATCH, or GENERAL.") + @Specification(number="2.8", text="In cases of abnormal execution, the `provider` **MUST** indicate an error using the idioms of the implementation language, with an associated `error code` and optional associated `error message`.") + @Specification(number="2.11", text="In cases of normal execution, the `provider` **MUST NOT** populate the `flag resolution` structure's `error message` field, or otherwise must populate it with a null or falsy value.") + @Specification(number="2.12", text="In cases of abnormal execution, the `evaluation details` structure's `error message` field **MAY** contain a string containing additional detail about the nature of the error.") @Test void up_to_provider_implementation() {} @Specification(number="2.5", text="In cases of normal execution, the provider SHOULD populate the " + diff --git a/src/test/java/dev/openfeature/javasdk/StructureTest.java b/src/test/java/dev/openfeature/javasdk/StructureTest.java new file mode 100644 index 000000000..eccb23c30 --- /dev/null +++ b/src/test/java/dev/openfeature/javasdk/StructureTest.java @@ -0,0 +1,71 @@ +package dev.openfeature.javasdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +public class StructureTest { + @Test public void noArgShouldContainEmptyAttributes() { + Structure structure = new Structure(); + assertEquals(0, structure.asMap().keySet().size()); + } + + @Test public void mapArgShouldContainNewMap() { + String KEY = "key"; + Map map = new HashMap() { + { + put(KEY, new Value(KEY)); + } + }; + Structure structure = new Structure(map); + assertEquals(KEY, structure.asMap().get(KEY).asString()); + assertNotSame(structure.asMap(), map); // should be a copy + } + + @Test public void addAndGetAddAndReturnValues() { + String BOOL_KEY = "bool"; + String STRING_KEY = "string"; + String INT_KEY = "int"; + String DOUBLE_KEY = "double"; + String DATE_KEY = "date"; + String STRUCT_KEY = "struct"; + String LIST_KEY = "list"; + String VALUE_KEY = "value"; + + boolean BOOL_VAL = true; + String STRING_VAL = "val"; + int INT_VAL = 13; + double DOUBLE_VAL = .5; + Instant DATE_VAL = Instant.now(); + Structure STRUCT_VAL = new Structure(); + List LIST_VAL = new ArrayList(); + Value VALUE_VAL = new Value(); + + Structure structure = new Structure(); + structure.add(BOOL_KEY, BOOL_VAL); + structure.add(STRING_KEY, STRING_VAL); + structure.add(INT_KEY, INT_VAL); + structure.add(DOUBLE_KEY, DOUBLE_VAL); + structure.add(DATE_KEY, DATE_VAL); + structure.add(STRUCT_KEY, STRUCT_VAL); + structure.add(LIST_KEY, LIST_VAL); + structure.add(VALUE_KEY, VALUE_VAL); + + assertEquals(BOOL_VAL, structure.getValue(BOOL_KEY).asBoolean()); + assertEquals(STRING_VAL, structure.getValue(STRING_KEY).asString()); + assertEquals(INT_VAL, structure.getValue(INT_KEY).asInteger()); + assertEquals(DOUBLE_VAL, structure.getValue(DOUBLE_KEY).asDouble()); + assertEquals(DATE_VAL, structure.getValue(DATE_KEY).asInstant()); + assertEquals(STRUCT_VAL, structure.getValue(STRUCT_KEY).asStructure()); + assertEquals(LIST_VAL, structure.getValue(LIST_KEY).asList()); + assertTrue(structure.getValue(VALUE_KEY).isNull()); + } +} diff --git a/src/test/java/dev/openfeature/javasdk/TestConstants.java b/src/test/java/dev/openfeature/javasdk/TestConstants.java new file mode 100644 index 000000000..945c32f67 --- /dev/null +++ b/src/test/java/dev/openfeature/javasdk/TestConstants.java @@ -0,0 +1,5 @@ +package dev.openfeature.javasdk; + +public class TestConstants { + public static final String BROKEN_MESSAGE = "This is borked."; +} diff --git a/src/test/java/dev/openfeature/javasdk/ValueTest.java b/src/test/java/dev/openfeature/javasdk/ValueTest.java new file mode 100644 index 000000000..12210321c --- /dev/null +++ b/src/test/java/dev/openfeature/javasdk/ValueTest.java @@ -0,0 +1,137 @@ +package dev.openfeature.javasdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +public class ValueTest { + @Test public void noArgShouldContainNull() { + Value value = new Value(); + assertTrue(value.isNull()); + } + + @Test public void objectArgShouldContainObject() { + try { + // int is a special case, see intObjectArgShouldConvertToInt() + List list = new ArrayList<>(); + list.add(true); + list.add("val"); + list.add(.5); + list.add(new Structure()); + list.add(new ArrayList()); + list.add(Instant.now()); + + int i = 0; + for (Object l: list) { + Value value = new Value(l); + assertEquals(list.get(i), value.asObject()); + i++; + } + } catch (Exception e) { + fail("No exception expected."); + } + } + + @Test public void intObjectArgShouldConvertToInt() { + try { + Object innerValue = 1; + Value value = new Value(innerValue); + assertEquals(innerValue, value.asInteger()); + } catch (Exception e) { + fail("No exception expected."); + } + } + + @Test public void invalidObjectArgShouldThrow() { + + class Something {} + + assertThrows(InstantiationException.class, () -> { + new Value(new Something()); + }); + } + + @Test public void boolArgShouldContainBool() { + boolean innerValue = true; + Value value = new Value(innerValue); + assertTrue(value.isBoolean()); + assertEquals(innerValue, value.asBoolean()); + } + + @Test public void numericArgShouldReturnDoubleOrInt() { + double innerDoubleValue = .75; + Value doubleValue = new Value(innerDoubleValue); + assertTrue(doubleValue.isNumber()); + assertEquals(1, doubleValue.asInteger()); // should be rounded + assertEquals(.75, doubleValue.asDouble()); + + int innerIntValue = 100; + Value intValue = new Value(innerIntValue); + assertTrue(intValue.isNumber()); + assertEquals(innerIntValue, intValue.asInteger()); + assertEquals(innerIntValue, intValue.asDouble()); + } + + @Test public void stringArgShouldContainString() { + String innerValue = "hi!"; + Value value = new Value(innerValue); + assertTrue(value.isString()); + assertEquals(innerValue, value.asString()); + } + + @Test public void dateShouldContainDate() { + Instant innerValue = Instant.now(); + Value value = new Value(innerValue); + assertTrue(value.isInstant()); + assertEquals(innerValue, value.asInstant()); + } + + @Test public void structureShouldContainStructure() { + String INNER_KEY = "key"; + String INNER_VALUE = "val"; + Structure innerValue = new Structure().add(INNER_KEY, INNER_VALUE); + Value value = new Value(innerValue); + assertTrue(value.isStructure()); + assertEquals(INNER_VALUE, value.asStructure().getValue(INNER_KEY).asString()); + } + + @Test public void listArgShouldContainList() { + String ITEM_VALUE = "val"; + List innerValue = new ArrayList(); + innerValue.add(new Value(ITEM_VALUE)); + Value value = new Value(innerValue); + assertTrue(value.isList()); + assertEquals(ITEM_VALUE, value.asList().get(0).asString()); + } + + @Test public void listMustBeOfValues() { + String item = "item"; + List list = new ArrayList<>(); + list.add(item); + try { + new Value((Object) list); + fail("Should fail due to creation of list of non-values."); + } catch (InstantiationException e) { + assertEquals("Invalid value type: class java.util.ArrayList", e.getMessage()); + } + } + + @Test public void emptyListAllowed() { + List list = new ArrayList<>(); + try { + Value value = new Value((Object) list); + assertTrue(value.isList()); + List values = value.asList(); + assertTrue(values.isEmpty()); + } catch (Exception e) { + fail("Unexpected exception occurred.", e); + } + } +} diff --git a/src/test/java/dev/openfeature/javasdk/integration/RunCucumberTest.java b/src/test/java/dev/openfeature/javasdk/integration/RunCucumberTest.java new file mode 100644 index 000000000..65c86c292 --- /dev/null +++ b/src/test/java/dev/openfeature/javasdk/integration/RunCucumberTest.java @@ -0,0 +1,16 @@ +package dev.openfeature.javasdk.integration; + +import org.junit.platform.suite.api.ConfigurationParameter; +import org.junit.platform.suite.api.IncludeEngines; +import org.junit.platform.suite.api.SelectClasspathResource; +import org.junit.platform.suite.api.Suite; + +import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME; + +@Suite +@IncludeEngines("cucumber") +@SelectClasspathResource("features") +@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty") +public class RunCucumberTest { + +} diff --git a/src/test/java/dev/openfeature/javasdk/integration/StepDefinitions.java b/src/test/java/dev/openfeature/javasdk/integration/StepDefinitions.java new file mode 100644 index 000000000..a7d9f91e9 --- /dev/null +++ b/src/test/java/dev/openfeature/javasdk/integration/StepDefinitions.java @@ -0,0 +1,282 @@ +package dev.openfeature.javasdk.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openfeature.contrib.providers.flagd.FlagdProvider; +import dev.openfeature.javasdk.Client; +import dev.openfeature.javasdk.EvaluationContext; +import dev.openfeature.javasdk.FlagEvaluationDetails; +import dev.openfeature.javasdk.OpenFeatureAPI; +import dev.openfeature.javasdk.Reason; +import dev.openfeature.javasdk.Structure; +import dev.openfeature.javasdk.Value; +import io.cucumber.java.BeforeAll; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +public class StepDefinitions { + + private static Client client; + private boolean booleanFlagValue; + private String stringFlagValue; + private int intFlagValue; + private double doubleFlagValue; + private Value objectFlagValue; + + private FlagEvaluationDetails booleanFlagDetails; + private FlagEvaluationDetails stringFlagDetails; + private FlagEvaluationDetails intFlagDetails; + private FlagEvaluationDetails doubleFlagDetails; + private FlagEvaluationDetails objectFlagDetails; + + private String contextAwareFlagKey; + private String contextAwareDefaultValue; + private EvaluationContext context; + private String contextAwareValue; + + private String notFoundFlagKey; + private String notFoundDefaultValue; + private FlagEvaluationDetails notFoundDetails; + private String typeErrorFlagKey; + private int typeErrorDefaultValue; + private FlagEvaluationDetails typeErrorDetails; + + @BeforeAll() + public static void setup() { + OpenFeatureAPI.getInstance().setProvider(new FlagdProvider()); + client = OpenFeatureAPI.getInstance().getClient(); + } + + /* + * Basic evaluation + */ + + // boolean value + @When("a boolean flag with key {string} is evaluated with default value {string}") + public void a_boolean_flag_with_key_boolean_flag_is_evaluated_with_default_value_false(String flagKey, + String defaultValue) { + this.booleanFlagValue = client.getBooleanValue(flagKey, Boolean.valueOf(defaultValue)); + } + + @Then("the resolved boolean value should be {string}") + public void the_resolved_boolean_value_should_be_true(String expected) { + assertEquals(Boolean.valueOf(expected), this.booleanFlagValue); + } + + // string value + @When("a string flag with key {string} is evaluated with default value {string}") + public void a_string_flag_with_key_is_evaluated_with_default_value(String flagKey, String defaultValue) { + this.stringFlagValue = client.getStringValue(flagKey, defaultValue); + } + + @Then("the resolved string value should be {string}") + public void the_resolved_string_value_should_be(String expected) { + assertEquals(expected, this.stringFlagValue); + } + + // integer value + @When("an integer flag with key {string} is evaluated with default value {int}") + public void an_integer_flag_with_key_is_evaluated_with_default_value(String flagKey, Integer defaultValue) { + this.intFlagValue = client.getIntegerValue(flagKey, defaultValue); + } + + @Then("the resolved integer value should be {int}") + public void the_resolved_integer_value_should_be(int expected) { + assertEquals(expected, this.intFlagValue); + } + + // float/double value + @When("a float flag with key {string} is evaluated with default value {double}") + public void a_float_flag_with_key_is_evaluated_with_default_value(String flagKey, double defaultValue) { + this.doubleFlagValue = client.getDoubleValue(flagKey, defaultValue); + } + + @Then("the resolved float value should be {double}") + public void the_resolved_float_value_should_be(double expected) { + assertEquals(expected, this.doubleFlagValue); + } + + // object value + @When("an object flag with key {string} is evaluated with a null default value") + public void an_object_flag_with_key_is_evaluated_with_a_null_default_value(String flagKey) { + this.objectFlagValue = client.getObjectValue(flagKey, new Value()); + } + + @Then("the resolved object value should be contain fields {string}, {string}, and {string}, with values {string}, {string} and {int}, respectively") + public void the_resolved_object_value_should_be_contain_fields_and_with_values_and_respectively(String boolField, + String stringField, String numberField, String boolValue, String stringValue, int numberValue) { + Structure structure = this.objectFlagValue.asStructure(); + + assertEquals(Boolean.valueOf(boolValue), structure.asMap().get(boolField).asBoolean()); + assertEquals(stringValue, structure.asMap().get(stringField).asString()); + assertEquals(numberValue, structure.asMap().get(numberField).asInteger()); + } + + /* + * Detailed evaluation + */ + + // boolean details + @When("a boolean flag with key {string} is evaluated with details and default value {string}") + public void a_boolean_flag_with_key_is_evaluated_with_details_and_default_value(String flagKey, + String defaultValue) { + this.booleanFlagDetails = client.getBooleanDetails(flagKey, Boolean.valueOf(defaultValue)); + } + + @Then("the resolved boolean details value should be {string}, the variant should be {string}, and the reason should be {string}") + public void the_resolved_boolean_value_should_be_the_variant_should_be_and_the_reason_should_be( + String expectedValue, + String expectedVariant, String expectedReason) { + assertEquals(Boolean.valueOf(expectedValue), booleanFlagDetails.getValue()); + assertEquals(expectedVariant, booleanFlagDetails.getVariant()); + assertEquals(expectedReason, booleanFlagDetails.getReason()); + } + + // string details + @When("a string flag with key {string} is evaluated with details and default value {string}") + public void a_string_flag_with_key_is_evaluated_with_details_and_default_value(String flagKey, + String defaultValue) { + this.stringFlagDetails = client.getStringDetails(flagKey, defaultValue); + } + + @Then("the resolved string details value should be {string}, the variant should be {string}, and the reason should be {string}") + public void the_resolved_string_value_should_be_the_variant_should_be_and_the_reason_should_be(String expectedValue, + String expectedVariant, String expectedReason) { + assertEquals(expectedValue, this.stringFlagDetails.getValue()); + assertEquals(expectedVariant, this.stringFlagDetails.getVariant()); + assertEquals(expectedReason, this.stringFlagDetails.getReason()); + } + + // integer details + @When("an integer flag with key {string} is evaluated with details and default value {int}") + public void an_integer_flag_with_key_is_evaluated_with_details_and_default_value(String flagKey, int defaultValue) { + this.intFlagDetails = client.getIntegerDetails(flagKey, defaultValue); + } + + @Then("the resolved integer details value should be {int}, the variant should be {string}, and the reason should be {string}") + public void the_resolved_integer_value_should_be_the_variant_should_be_and_the_reason_should_be(int expectedValue, + String expectedVariant, String expectedReason) { + assertEquals(expectedValue, this.intFlagDetails.getValue()); + assertEquals(expectedVariant, this.intFlagDetails.getVariant()); + assertEquals(expectedReason, this.intFlagDetails.getReason()); + } + + // float/double details + @When("a float flag with key {string} is evaluated with details and default value {double}") + public void a_float_flag_with_key_is_evaluated_with_details_and_default_value(String flagKey, double defaultValue) { + this.doubleFlagDetails = client.getDoubleDetails(flagKey, defaultValue); + } + + @Then("the resolved float details value should be {double}, the variant should be {string}, and the reason should be {string}") + public void the_resolved_float_value_should_be_the_variant_should_be_and_the_reason_should_be(double expectedValue, + String expectedVariant, String expectedReason) { + assertEquals(expectedValue, this.doubleFlagDetails.getValue()); + assertEquals(expectedVariant, this.doubleFlagDetails.getVariant()); + assertEquals(expectedReason, this.doubleFlagDetails.getReason()); + } + + // object details + @When("an object flag with key {string} is evaluated with details and a null default value") + public void an_object_flag_with_key_is_evaluated_with_details_and_a_null_default_value(String flagKey) { + this.objectFlagDetails = client.getObjectDetails(flagKey, new Value()); + } + + @Then("the resolved object details value should be contain fields {string}, {string}, and {string}, with values {string}, {string} and {int}, respectively") + public void the_resolved_object_value_should_be_contain_fields_and_with_values_and_respectively_again( + String boolField, + String stringField, String numberField, String boolValue, String stringValue, int numberValue) { + Structure structure = this.objectFlagDetails.getValue().asStructure(); + + assertEquals(Boolean.valueOf(boolValue), structure.asMap().get(boolField).asBoolean()); + assertEquals(stringValue, structure.asMap().get(stringField).asString()); + assertEquals(numberValue, structure.asMap().get(numberField).asInteger()); + } + + @Then("the variant should be {string}, and the reason should be {string}") + public void the_variant_should_be_and_the_reason_should_be(String expectedVariant, String expectedReason) { + assertEquals(expectedVariant, this.objectFlagDetails.getVariant()); + assertEquals(expectedReason, this.objectFlagDetails.getReason()); + } + + /* + * Context-aware evaluation + */ + + @When("context contains keys {string}, {string}, {string}, {string} with values {string}, {string}, {int}, {string}") + public void context_contains_keys_with_values(String field1, String field2, String field3, String field4, + String value1, String value2, Integer value3, String value4) { + this.context = new EvaluationContext() + .add(field1, value1) + .add(field2, value2) + .add(field3, value3) + .add(field4, Boolean.valueOf(value4)); + } + + @When("a flag with key {string} is evaluated with default value {string}") + public void an_a_flag_with_key_is_evaluated(String flagKey, String defaultValue) { + contextAwareFlagKey = flagKey; + contextAwareDefaultValue = defaultValue; + contextAwareValue = client.getStringValue(flagKey, contextAwareDefaultValue, context); + + } + + @Then("the resolved string response should be {string}") + public void the_resolved_string_response_should_be(String expected) { + assertEquals(expected, this.contextAwareValue); + } + + @Then("the resolved flag value is {string} when the context is empty") + public void the_resolved_flag_value_is_when_the_context_is_empty(String expected) { + String emptyContextValue = client.getStringValue(contextAwareFlagKey, contextAwareDefaultValue, + new EvaluationContext()); + assertEquals(expected, emptyContextValue); + } + + /* + * Errors + */ + + // not found + @When("a non-existent string flag with key {string} is evaluated with details and a default value {string}") + public void a_non_existent_string_flag_with_key_is_evaluated_with_details_and_a_default_value(String flagKey, + String defaultValue) { + notFoundFlagKey = flagKey; + notFoundDefaultValue = defaultValue; + notFoundDetails = client.getStringDetails(notFoundFlagKey, notFoundDefaultValue); + } + + @Then("then the default string value should be returned") + public void then_the_default_string_value_should_be_returned() { + assertEquals(notFoundDefaultValue, notFoundDetails.getValue()); + } + + @Then("the reason should indicate an error and the error code should indicate a missing flag with {string}") + public void the_reason_should_indicate_an_error_and_the_error_code_should_be_flag_not_found(String errorCode) { + assertEquals(Reason.ERROR.toString(), notFoundDetails.getReason()); + assertTrue(notFoundDetails.getErrorMessage().contains(errorCode)); + // TODO: add errorCode assertion once flagd provider is updated. + } + + // type mismatch + @When("a string flag with key {string} is evaluated as an integer, with details and a default value {int}") + public void a_string_flag_with_key_is_evaluated_as_an_integer_with_details_and_a_default_value(String flagKey, + int defaultValue) { + typeErrorFlagKey = flagKey; + typeErrorDefaultValue = defaultValue; + typeErrorDetails = client.getIntegerDetails(typeErrorFlagKey, typeErrorDefaultValue); + } + + @Then("then the default integer value should be returned") + public void then_the_default_integer_value_should_be_returned() { + assertEquals(typeErrorDefaultValue, typeErrorDetails.getValue()); + } + + @Then("the reason should indicate an error and the error code should indicate a type mismatch with {string}") + public void the_reason_should_indicate_an_error_and_the_error_code_should_be_type_mismatch(String errorCode) { + assertEquals(Reason.ERROR.toString(), typeErrorDetails.getReason()); + assertTrue(typeErrorDetails.getErrorMessage().contains(errorCode)); + // TODO: add errorCode assertion once flagd provider is updated. + } + +} diff --git a/src/test/resources/features/.gitignore b/src/test/resources/features/.gitignore new file mode 100644 index 000000000..ce4de1a72 --- /dev/null +++ b/src/test/resources/features/.gitignore @@ -0,0 +1 @@ +evaluation.feature \ No newline at end of file diff --git a/src/test/resources/features/.gitkeep b/src/test/resources/features/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/test-harness b/test-harness new file mode 160000 index 000000000..e7379cd00 --- /dev/null +++ b/test-harness @@ -0,0 +1 @@ +Subproject commit e7379cd0070f8907cacdc535184f8f626bf25e01 diff --git a/version.txt b/version.txt new file mode 100644 index 000000000..ee1372d33 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +0.2.2