From ae58ba6ace88e7d92af4d2ecb30f20f4cc8d9be2 Mon Sep 17 00:00:00 2001 From: Benjamin Cavy Date: Mon, 25 Mar 2024 10:36:30 +0100 Subject: [PATCH] Initial commit --- .github/CODE_OF_CONDUCT.md | 73 + .github/workflows/gh-release.yml | 28 + .github/workflows/publish.yml | 32 + .github/workflows/release.yml | 26 + .github/workflows/test.yml | 36 + .gitignore | 3 + .java-version | 1 + LICENSE | 176 +++ README.md | 11 + pom.xml | 190 +++ .../java/fr/maif/ClientConfiguration.java | 29 + .../fr/maif/FeatureCacheConfiguration.java | 60 + .../fr/maif/FeatureClientErrorStrategy.java | 114 ++ src/main/java/fr/maif/IzanamiClient.java | 167 ++ .../java/fr/maif/errors/IzanamiError.java | 10 + .../java/fr/maif/errors/IzanamiException.java | 11 + .../fr/maif/features/ActivationCondition.java | 18 + .../maif/features/ActivationDayOfWeeks.java | 14 + .../java/fr/maif/features/ActivationRule.java | 5 + src/main/java/fr/maif/features/Feature.java | 36 + .../fr/maif/features/FeatureContextPath.java | 8 + .../fr/maif/features/FeatureOverload.java | 54 + .../java/fr/maif/features/FeaturePeriod.java | 24 + .../java/fr/maif/features/HourPeriod.java | 21 + .../java/fr/maif/features/RequestContext.java | 10 + src/main/java/fr/maif/features/UserList.java | 15 + .../java/fr/maif/features/UserPercentage.java | 19 + src/main/java/fr/maif/http/HttpRequester.java | 69 + .../java/fr/maif/http/IzanamiHttpClient.java | 47 + .../java/fr/maif/http/IzanamiHttpRequest.java | 20 + .../fr/maif/http/IzanamiHttpResponse.java | 11 + src/main/java/fr/maif/http/ResponseUtils.java | 202 +++ src/main/java/fr/maif/http/Result.java | 45 + .../java/fr/maif/requests/FeatureRequest.java | 208 +++ .../java/fr/maif/requests/FeatureService.java | 159 ++ .../IzanamiConnectionInformation.java | 84 + .../maif/requests/SingleFeatureRequest.java | 77 + .../maif/requests/SpecificFeatureRequest.java | 48 + src/main/resources/.gitkeep | 0 src/test/java/fr/maif/IzanamiClientTest.java | 1407 +++++++++++++++++ src/test/java/fr/maif/Mocks.java | 195 +++ src/test/resources/.gitkeep | 0 src/test/resources/logback-test.xml | 18 + 43 files changed, 3781 insertions(+) create mode 100644 .github/CODE_OF_CONDUCT.md create mode 100644 .github/workflows/gh-release.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .java-version create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pom.xml create mode 100644 src/main/java/fr/maif/ClientConfiguration.java create mode 100644 src/main/java/fr/maif/FeatureCacheConfiguration.java create mode 100644 src/main/java/fr/maif/FeatureClientErrorStrategy.java create mode 100644 src/main/java/fr/maif/IzanamiClient.java create mode 100644 src/main/java/fr/maif/errors/IzanamiError.java create mode 100644 src/main/java/fr/maif/errors/IzanamiException.java create mode 100644 src/main/java/fr/maif/features/ActivationCondition.java create mode 100644 src/main/java/fr/maif/features/ActivationDayOfWeeks.java create mode 100644 src/main/java/fr/maif/features/ActivationRule.java create mode 100644 src/main/java/fr/maif/features/Feature.java create mode 100644 src/main/java/fr/maif/features/FeatureContextPath.java create mode 100644 src/main/java/fr/maif/features/FeatureOverload.java create mode 100644 src/main/java/fr/maif/features/FeaturePeriod.java create mode 100644 src/main/java/fr/maif/features/HourPeriod.java create mode 100644 src/main/java/fr/maif/features/RequestContext.java create mode 100644 src/main/java/fr/maif/features/UserList.java create mode 100644 src/main/java/fr/maif/features/UserPercentage.java create mode 100644 src/main/java/fr/maif/http/HttpRequester.java create mode 100644 src/main/java/fr/maif/http/IzanamiHttpClient.java create mode 100644 src/main/java/fr/maif/http/IzanamiHttpRequest.java create mode 100644 src/main/java/fr/maif/http/IzanamiHttpResponse.java create mode 100644 src/main/java/fr/maif/http/ResponseUtils.java create mode 100644 src/main/java/fr/maif/http/Result.java create mode 100644 src/main/java/fr/maif/requests/FeatureRequest.java create mode 100644 src/main/java/fr/maif/requests/FeatureService.java create mode 100644 src/main/java/fr/maif/requests/IzanamiConnectionInformation.java create mode 100644 src/main/java/fr/maif/requests/SingleFeatureRequest.java create mode 100644 src/main/java/fr/maif/requests/SpecificFeatureRequest.java create mode 100644 src/main/resources/.gitkeep create mode 100644 src/test/java/fr/maif/IzanamiClientTest.java create mode 100644 src/test/java/fr/maif/Mocks.java create mode 100644 src/test/resources/.gitkeep create mode 100644 src/test/resources/logback-test.xml diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..be63cac --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,73 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at [INSERT EMAIL ADDRESS]. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org \ No newline at end of file diff --git a/.github/workflows/gh-release.yml b/.github/workflows/gh-release.yml new file mode 100644 index 0000000..ebaef5a --- /dev/null +++ b/.github/workflows/gh-release.yml @@ -0,0 +1,28 @@ +name: Github release + +on: + push: + tags: + - v2.** + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + cache: maven + - name: Build with Maven + run: mvn -B clean package -DskipTests --file pom.xml + - name: github release + uses: softprops/action-gh-release@v2 + with: + files: | + target/izanami-java-client.jar + target/izanami-java-client-javadoc.jar + target/izanami-java-client-sources.jar \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..0bc1692 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,32 @@ +name: Publish to maven central + +on: + push: + tags: + - v2.** + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + cache: maven + server-id: ossrh + server-username: OSSRH_USERNAME + server-password: OSSRH_PASSWORD + gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} + gpg-passphrase: MAVEN_GPG_PASSPHRASE + - name: Publish to Apache Maven Central + run: mvn deploy -Pci-cd + env: + OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} + OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + #MAVEN_GPG_KEY: ${{ secrets.GPG_PRIVATE_KEY }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8944036 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,26 @@ +name: Maven release + +on: workflow_dispatch + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + cache: maven + - name: maven release + uses: qcastel/github-actions-maven-release@v1.12.41 + env: + JAVA_HOME: /usr/lib/jvm/java-11-openjdk/ + with: + release-branch-name: "main" + maven-args: " -Dmaven.deploy.skip=true" + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + git-release-bot-name: "Release bot" + git-release-bot-email: "benjamin.cavy@maif.fr" \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..4912653 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +# This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven + +# 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. + +name: Test + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + if: ${{ ! startsWith(github.event.head_commit.message, '[maven-release-plugin]') }} + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + cache: maven + - name: Build with Maven + run: mvn -B clean install --file pom.xml + + # Optional: Uploads the full dependency graph to GitHub to improve the quality of Dependabot alerts this repository can receive + #- name: Update dependency graph + # uses: advanced-security/maven-dependency-submission-action@571e99aab1055c2e71a1e2309b9691de18d6b7d6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e018193 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Project specific +target +.idea/ diff --git a/.java-version b/.java-version new file mode 100644 index 0000000..2dbc24b --- /dev/null +++ b/.java-version @@ -0,0 +1 @@ +11.0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..17bde2e --- /dev/null +++ b/LICENSE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1cedf85 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# Izanami Java client + +This clients is designed for requesting Izanami from client applications. In addition to querying remote Izanami, this client offers to main features: + +- Client is able to cache flag activation conditions and to recompute activation locally, therefore saving many request to remote Izanami. +- Moreover, client allows to define error strategies when flag status can't be fetched. + +It only supports Izanami versions > 2. + + +To learn more, read the client [documentation](https://maif.github.io/izanami/docs/clients/java/). \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..ec048bd --- /dev/null +++ b/pom.xml @@ -0,0 +1,190 @@ + + 4.0.0 + fr.maif + izanami-client + 2.0.0-SNAPSHOT + jar + ${project.groupId}:${project.artifactId} + Java client for Izanami in version > 2 + https://github.com/MAIF/izanami + + + UTF-8 + 11 + 11 + 3.3.0 + 5.8.2 + 1.3 + + + + + The Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + + scm:git:git@github.com:MAIF/izanami-java-client.git + scm:git:git@github.com:MAIF/izanami-java-client.git + https://github.com/MAIF/izanami-java-client/tree/main + v2.0.0-beta1 + + + + + ossrh + https://s01.oss.sonatype.org/content/repositories/snapshots + + + ossrh + https://s01.oss.sonatype.org/service/local/staging/deploy/maven2 + + + + + + + Benjamin Cavy + benjamin.cavy@maif.fr + MAIF + https://www.maif.fr/ + + + + + izanami-java-client + + + org.apache.maven.plugins + maven-jar-plugin + ${maven-jar-plugin.version} + + + org.apache.maven.plugins + maven-source-plugin + 3.3.0 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.6.3 + + + attach-javadocs + + jar + + + + + all,-missing + + + + org.apache.maven.plugins + maven-release-plugin + 3.0.1 + + v@{project.version} + + + + + + + + ci-cd + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.1 + + + sign-artifacts + verify + + sign + + + + + + + + + + + + + ch.qos.logback + logback-classic + 1.4.14 + + + org.slf4j + slf4j-api + 2.0.9 + + + commons-codec + commons-codec + 1.16.0 + + + com.github.ben-manes.caffeine + caffeine + 3.1.8 + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + 2.16.0 + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + 2.16.0 + + + com.fasterxml.jackson.core + jackson-databind + 2.16.0 + + + org.awaitility + awaitility + 4.2.1 + + + + org.assertj + assertj-core + 3.24.2 + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit-jupiter-engine.version} + test + + + org.wiremock + wiremock + 3.3.1 + test + + + \ No newline at end of file diff --git a/src/main/java/fr/maif/ClientConfiguration.java b/src/main/java/fr/maif/ClientConfiguration.java new file mode 100644 index 0000000..2fe089d --- /dev/null +++ b/src/main/java/fr/maif/ClientConfiguration.java @@ -0,0 +1,29 @@ +package fr.maif; + +import fr.maif.http.IzanamiHttpClient; +import fr.maif.requests.IzanamiConnectionInformation; + +import java.time.Duration; + + +public class ClientConfiguration { + public final IzanamiConnectionInformation connectionInformation; + public final FeatureClientErrorStrategy errorStrategy; + public final FeatureCacheConfiguration cacheConfiguration; + public final IzanamiHttpClient httpClient; + public final Duration callTimeout; + + public ClientConfiguration( + IzanamiConnectionInformation connectionInformation, + FeatureClientErrorStrategy errorStrategy, + FeatureCacheConfiguration cacheConfiguration, + IzanamiHttpClient httpClient, + Duration callTimeout + ) { + this.connectionInformation = connectionInformation; + this.errorStrategy = errorStrategy; + this.cacheConfiguration = cacheConfiguration; + this.httpClient = httpClient; + this.callTimeout = callTimeout; + } +} diff --git a/src/main/java/fr/maif/FeatureCacheConfiguration.java b/src/main/java/fr/maif/FeatureCacheConfiguration.java new file mode 100644 index 0000000..abe8acf --- /dev/null +++ b/src/main/java/fr/maif/FeatureCacheConfiguration.java @@ -0,0 +1,60 @@ +package fr.maif; + +import java.time.Duration; + +/** + * This class allows to configure cache behaviour for izanami client. + */ +public class FeatureCacheConfiguration { + /** + * Indicate whether cache should be used (when possible) when querying feature activation status. + */ + public final boolean enabled; + /** + * Cache refresh interval, the cache will refresh all stored features at this interval + */ + public final Duration refreshInterval; + + private FeatureCacheConfiguration(Builder builder) { + enabled = builder.enabled; + refreshInterval = builder.refreshInterval; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public static final class Builder { + private boolean enabled = false; + private Duration refreshInterval = Duration.ofMinutes(10L); + + private Builder() { + } + + /** + * @param val whether cache should be used (when possible) when querying feature activation status. + * @return updated builder + */ + public Builder enabled(boolean val) { + enabled = val; + return this; + } + + /** + * @param val Cache refresh interval, the cache will refresh all stored features at this interval + * @return updated builder + */ + public Builder withRefreshInterval(Duration val) { + refreshInterval = val; + return this; + } + + /** + * Build actual cache configuration + * @return a new FeatureCacheConfiguration with this builder values + */ + public FeatureCacheConfiguration build() { + return new FeatureCacheConfiguration(this); + } + } +} diff --git a/src/main/java/fr/maif/FeatureClientErrorStrategy.java b/src/main/java/fr/maif/FeatureClientErrorStrategy.java new file mode 100644 index 0000000..1fb1020 --- /dev/null +++ b/src/main/java/fr/maif/FeatureClientErrorStrategy.java @@ -0,0 +1,114 @@ +package fr.maif; + +import fr.maif.errors.IzanamiError; +import fr.maif.errors.IzanamiException; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +/** + * Strategy to use when one feature activation status can't be fetched or computed locally. + * Izanami handle errors as follows : + *
    + *
  1. If feature can't be retrieved from server, the client will use last cached version of the feature (event if cache is disabled) to compute activation status. This computation is possible only for non script features.
  2. + *
  3. If the client is configured to not use last cached version, error strategy will be used
  4. + *
+ * + * Therefore, error strategy are used in 3 scenarios : + * + * + * Error strategy can be specified at client level, query level and query feature levels. + * Priority is : query feature strategy over query strategy over client strategy + */ +public abstract class FeatureClientErrorStrategy> { + public boolean lastKnownFallbackAllowed = true; + + public abstract CompletableFuture handleError(IzanamiError error); + + /** + * + * @param shouldUseLastKnownStrategy Indicate whether client is allowed to use last fetched feature state to compute activation status locally if feature retrieval goes wrong. Default is true. + * @return updated strategy + */ + public T fallbackOnLastKnownStrategy(boolean shouldUseLastKnownStrategy) { + this.lastKnownFallbackAllowed = shouldUseLastKnownStrategy; + return (T) this; + } + + + public static NullValueStrategy nullValueStrategy() { + return new NullValueStrategy(); + } + + /** + * + * @return an error strategy that either throws an IzanamiException (if query was a single feature query) or values feature activation status to null (if query was a multiple feature query). + */ + public static FailStrategy failStrategy() { + return new FailStrategy(); + } + + /** + * + * @param defaultValue default value to use as activation status + * @return an error strategy that use provided value as activation status + */ + public static DefaultValueStrategy defaultValueStrategy(boolean defaultValue) { + return new DefaultValueStrategy(defaultValue); + } + + /** + * + * @param callback function that will be called to compute feature activation status + * @return an error strategy that calls provided callback to compute activation status + */ + public static CallbackStrategy callbackStrategy( + Function> callback + ) { + return new CallbackStrategy(callback); + } + + public static class NullValueStrategy extends FeatureClientErrorStrategy { + @Override + public CompletableFuture handleError(IzanamiError error) { + return CompletableFuture.completedFuture(null); + } + } + + public static class FailStrategy extends FeatureClientErrorStrategy { + @Override + public CompletableFuture handleError(IzanamiError error) { + return CompletableFuture.failedFuture(new IzanamiException(error.message)); + } + } + + public static class DefaultValueStrategy extends FeatureClientErrorStrategy { + public final boolean value; + + public DefaultValueStrategy(boolean value) { + this.value = value; + } + + @Override + public CompletableFuture handleError(IzanamiError error) { + return CompletableFuture.completedFuture(value); + } + } + + public static class CallbackStrategy extends FeatureClientErrorStrategy { + private Function> callback; + public CallbackStrategy(Function> callback) { + this.callback = callback; + } + + @Override + public CompletableFuture handleError(IzanamiError error) { + return callback.apply(error); + } + } + +} diff --git a/src/main/java/fr/maif/IzanamiClient.java b/src/main/java/fr/maif/IzanamiClient.java new file mode 100644 index 0000000..25074d3 --- /dev/null +++ b/src/main/java/fr/maif/IzanamiClient.java @@ -0,0 +1,167 @@ +package fr.maif; + +import fr.maif.http.IzanamiHttpClient; +import fr.maif.requests.FeatureRequest; +import fr.maif.requests.FeatureService; +import fr.maif.requests.IzanamiConnectionInformation; +import fr.maif.requests.SingleFeatureRequest; + +import java.time.Duration; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +/** + * Client to query Izanami for feature activation status. + * This should be instantiated only once by application. + */ +public class IzanamiClient { + private final ClientConfiguration configuration; + private final FeatureService featureService; + private CompletableFuture loader; + + public IzanamiClient( + IzanamiConnectionInformation connectionInformation, + Optional errorStrategy, + Optional cacheConfiguration, + Optional httpClient, + Optional duration, + Set idsToPreload + ) { + this.configuration = new ClientConfiguration( + connectionInformation, + errorStrategy.orElseGet(FeatureClientErrorStrategy::nullValueStrategy), + cacheConfiguration.orElseGet(() -> FeatureCacheConfiguration.newBuilder().enabled(false).build()), + httpClient.orElseGet(IzanamiHttpClient.DefaultIzanamiHttpClient::new), + duration.orElse(Duration.ofSeconds(10L)) + ); + + this.featureService = new FeatureService(configuration); + + if(Objects.nonNull(idsToPreload) && !idsToPreload.isEmpty()) { + this.loader = featureService.featureStates(FeatureRequest.newFeatureRequest().withFeatures(idsToPreload)).thenAccept(v -> {}); + } else { + this.loader = CompletableFuture.completedFuture(null); + } + } + + /** + * Create a new izanami client builder + * @param connectionInformation must contain connection information for your Izanami instance + * @return an initialized new izanami client builder + */ + public static IzanamiClientBuilder newBuilder(IzanamiConnectionInformation connectionInformation) { + return new IzanamiClientBuilder(connectionInformation); + } + + /** + * Retrieve activation status for features in given request. + * @param request request containing feature ids to query and optionally more information about query, cache use or error strategy. + * @return a CompletableFuture containing a Map that associates each feature to its activation status + */ + public CompletableFuture> checkFeatureActivations(FeatureRequest request) { + return featureService.featureStates(request); + } + + /** + * Retrieve activation status for a single feature + * @param request containing feature id to query and optionally more information about query, cache use or error strategy. + * @return a CompletableFuture containing activation status for requested feature + */ + public CompletableFuture checkFeatureActivation(SingleFeatureRequest request) { + return featureService.featureStates(request); + } + + public CompletableFuture isLoaded() { + return loader; + } + + + public static class IzanamiClientBuilder { + private final IzanamiConnectionInformation connectionInformation; + private Optional errorStrategy = Optional.empty(); + private Optional cacheConfiguration = Optional.empty(); + private Optional client = Optional.empty(); + private Optional callTimeout = Optional.empty(); + private Set idsToPreload = Collections.emptySet(); + + private IzanamiClientBuilder(IzanamiConnectionInformation connectionInformation) { + this.connectionInformation = connectionInformation; + } + + /** + * Specify error strategy to use for this client. This strategy can be overridden both at query and query feature levels. + * @param errorStrategy error strategy to use when activation status can't be retrieved / computed for a feature + * @return updated builder + */ + public IzanamiClientBuilder withErrorStrategy(FeatureClientErrorStrategy errorStrategy) { + this.errorStrategy = Optional.ofNullable(errorStrategy); + return this; + } + + /** + * Specify call timeout to use for default HTTP client. This indicates duration after which call to remote Izanami will timeout. + * @param duration timeout before failing remote izanami query + * @return updated builder + */ + public IzanamiClientBuilder withCallTimeout(Duration duration) { + this.callTimeout = Optional.ofNullable(duration); + return this; + } + + /** + * Specify cache configuration to use fot this client. Cache behaviour may bo overridden both at query and query feature levels. + * @param cacheConfiguration cache configuration + * @return updated builder + */ + public IzanamiClientBuilder withCacheConfiguration(FeatureCacheConfiguration cacheConfiguration) { + this.cacheConfiguration = Optional.ofNullable(cacheConfiguration); + return this; + } + + /** + * Specify custom http client to use for this client. this may be usefull if you need to a MTLS or proxy configuration on your izanami calls. + * @param client custom client to use + * @return updated builder + */ + public IzanamiClientBuilder withCustomClient(IzanamiHttpClient client) { + this.client = Optional.ofNullable(client); + return this; + } + + /** + * Indicate feature to preload + * @param ids ids of feature to preload + * @return updated builder + */ + public IzanamiClientBuilder withPreloadedFeatures(Set ids) { + this.idsToPreload = ids; + return this; + } + + /** + * Indicate feature to preload + * @param ids ids of feature to preload + * @return updated builder + */ + public IzanamiClientBuilder withPreloadedFeatures(String... ids) { + this.idsToPreload = Arrays.stream(ids).collect(Collectors.toSet()); + return this; + } + + /** + * Build izanami client with this builder current information + * @return a new izanami client + */ + public IzanamiClient build() { + return new IzanamiClient( + connectionInformation, + errorStrategy, + cacheConfiguration, + client, + callTimeout, + idsToPreload + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/fr/maif/errors/IzanamiError.java b/src/main/java/fr/maif/errors/IzanamiError.java new file mode 100644 index 0000000..c16d9dc --- /dev/null +++ b/src/main/java/fr/maif/errors/IzanamiError.java @@ -0,0 +1,10 @@ +package fr.maif.errors; + +public class IzanamiError extends RuntimeException { + public final String message; + + public IzanamiError(String message) { + this.message = message; + } + +} diff --git a/src/main/java/fr/maif/errors/IzanamiException.java b/src/main/java/fr/maif/errors/IzanamiException.java new file mode 100644 index 0000000..feaf38f --- /dev/null +++ b/src/main/java/fr/maif/errors/IzanamiException.java @@ -0,0 +1,11 @@ +package fr.maif.errors; + +public class IzanamiException extends RuntimeException { + public IzanamiException(String message) { + super(message); + } + + public IzanamiException(Exception e) { + super(e); + } +} diff --git a/src/main/java/fr/maif/features/ActivationCondition.java b/src/main/java/fr/maif/features/ActivationCondition.java new file mode 100644 index 0000000..2c31938 --- /dev/null +++ b/src/main/java/fr/maif/features/ActivationCondition.java @@ -0,0 +1,18 @@ +package fr.maif.features; + +import java.util.Optional; + +public class ActivationCondition { + private FeaturePeriod period; + private ActivationRule rule; + + public ActivationCondition(FeaturePeriod period, ActivationRule rule) { + this.period = period; + this.rule = rule; + } + + public boolean active(String user, String featureId) { + return Optional.ofNullable(period).map(p -> p.active(user)).orElse(true) && + Optional.ofNullable(rule).map(r -> r.active(user, featureId)).orElse(true); + } +} diff --git a/src/main/java/fr/maif/features/ActivationDayOfWeeks.java b/src/main/java/fr/maif/features/ActivationDayOfWeeks.java new file mode 100644 index 0000000..fea10ee --- /dev/null +++ b/src/main/java/fr/maif/features/ActivationDayOfWeeks.java @@ -0,0 +1,14 @@ +package fr.maif.features; + +import java.time.DayOfWeek; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Set; + +public class ActivationDayOfWeeks { + public Set days; + + public boolean active(ZoneId timezone) { + return days.contains(LocalDateTime.now().atZone(timezone).getDayOfWeek()); + } +} diff --git a/src/main/java/fr/maif/features/ActivationRule.java b/src/main/java/fr/maif/features/ActivationRule.java new file mode 100644 index 0000000..37b26d1 --- /dev/null +++ b/src/main/java/fr/maif/features/ActivationRule.java @@ -0,0 +1,5 @@ +package fr.maif.features; + +public interface ActivationRule { + boolean active(String user, String feature); +} diff --git a/src/main/java/fr/maif/features/Feature.java b/src/main/java/fr/maif/features/Feature.java new file mode 100644 index 0000000..2e98bda --- /dev/null +++ b/src/main/java/fr/maif/features/Feature.java @@ -0,0 +1,36 @@ +package fr.maif.features; + +import java.util.*; + +public class Feature { + public final String id; + public final String name; + public final String project; + public final boolean active; + + public Map conditions = new HashMap<>(); + + public Feature(String id, String name, String project, boolean active, Map conditions) { + this.id = id; + this.name = name; + this.project = project; + this.active = active; + this.conditions = conditions; + } + + public Optional active(String context, String user) { + String ctx = Optional.ofNullable(context).orElse(""); + String contextToUse = conditions.keySet().stream() + .filter(ctx::startsWith) + .max(Comparator.comparingInt(String::length)) + .get(); + + FeatureOverload overload = conditions.get(contextToUse); + return overload.active(user, name); + } + + public static enum FeatureType { + CLASSICAL, + SCRIPT + } +} diff --git a/src/main/java/fr/maif/features/FeatureContextPath.java b/src/main/java/fr/maif/features/FeatureContextPath.java new file mode 100644 index 0000000..a2cf332 --- /dev/null +++ b/src/main/java/fr/maif/features/FeatureContextPath.java @@ -0,0 +1,8 @@ +package fr.maif.features; + +import java.util.Collections; +import java.util.List; + +public class FeatureContextPath { + public List elements = Collections.emptyList(); +} diff --git a/src/main/java/fr/maif/features/FeatureOverload.java b/src/main/java/fr/maif/features/FeatureOverload.java new file mode 100644 index 0000000..5640d85 --- /dev/null +++ b/src/main/java/fr/maif/features/FeatureOverload.java @@ -0,0 +1,54 @@ +package fr.maif.features; + +import java.util.Optional; +import java.util.Set; + +public abstract class FeatureOverload { + public final boolean enabled; + public final Feature.FeatureType featureType; + public Optional active(String user, String name) { + if(featureType == Feature.FeatureType.CLASSICAL) { + ClassicalOverload o = (ClassicalOverload)this; + boolean active = enabled && ( + o.conditions.isEmpty() || + o.conditions.stream().anyMatch(cond -> cond.active(user, name)) + ); + return Optional.of(active); + } else { + return Optional.empty(); + } + } + + public FeatureOverload(Feature.FeatureType featureType, boolean enabled) { + this.featureType = featureType; + this.enabled = enabled; + } + + public static class ClassicalOverload extends FeatureOverload { + public Set conditions; + + public ClassicalOverload(boolean enabled, Set conditions) { + super(Feature.FeatureType.CLASSICAL, enabled); + this.conditions = conditions; + } + } + + public static class WasmFeatureOverload extends FeatureOverload { + public WasmConfig wasmConfig; + + public WasmFeatureOverload(boolean enabled, WasmConfig wasmConfig) { + super(Feature.FeatureType.SCRIPT, enabled); + this.wasmConfig = wasmConfig; + } + } + + public static class WasmConfig { + public final String name; + + public WasmConfig(String name) { + this.name = name; + } + } +} + + diff --git a/src/main/java/fr/maif/features/FeaturePeriod.java b/src/main/java/fr/maif/features/FeaturePeriod.java new file mode 100644 index 0000000..d74f3a9 --- /dev/null +++ b/src/main/java/fr/maif/features/FeaturePeriod.java @@ -0,0 +1,24 @@ +package fr.maif.features; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Collections; +import java.util.Optional; +import java.util.Set; + +public class FeaturePeriod { + public Optional begin = Optional.empty(); + public Optional end = Optional.empty(); + public Set hourPeriods = Collections.emptySet(); + public Optional activationDays = Optional.empty(); + public ZoneId timezone = ZoneId.systemDefault(); + + public boolean active(String user) { + var now = LocalDateTime.now().atZone(timezone).toInstant(); + return begin.map(i -> i.isBefore(now)).orElse(true) && + end.map(i -> i.isAfter(now)).orElse(true) && + (hourPeriods.isEmpty() || hourPeriods.stream().anyMatch(p -> p.active(timezone))) && + activationDays.stream().allMatch(d -> d.active(timezone)); + } +} diff --git a/src/main/java/fr/maif/features/HourPeriod.java b/src/main/java/fr/maif/features/HourPeriod.java new file mode 100644 index 0000000..60c439a --- /dev/null +++ b/src/main/java/fr/maif/features/HourPeriod.java @@ -0,0 +1,21 @@ +package fr.maif.features; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; + +public class HourPeriod { + public LocalTime startTime; + public LocalTime endTime; + + public boolean active(ZoneId timezone) { + var zonedStart = LocalDateTime.of(LocalDate.now(), startTime) + .atZone(timezone).toInstant(); + var zonedEnd = LocalDateTime.of(LocalDate.now(), endTime) + .atZone(timezone).toInstant(); + var now = LocalDateTime.now().atZone(timezone).toInstant(); + + return zonedStart.isBefore(now) && zonedEnd.isAfter(now); + } +} diff --git a/src/main/java/fr/maif/features/RequestContext.java b/src/main/java/fr/maif/features/RequestContext.java new file mode 100644 index 0000000..3297f77 --- /dev/null +++ b/src/main/java/fr/maif/features/RequestContext.java @@ -0,0 +1,10 @@ +package fr.maif.features; + +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.time.Instant; + +public class RequestContext { + public String user; +} diff --git a/src/main/java/fr/maif/features/UserList.java b/src/main/java/fr/maif/features/UserList.java new file mode 100644 index 0000000..b37f355 --- /dev/null +++ b/src/main/java/fr/maif/features/UserList.java @@ -0,0 +1,15 @@ +package fr.maif.features; + +import java.util.Set; + +public class UserList implements ActivationRule { + public Set users; + + public UserList(Set users) { + this.users = users; + } + + public boolean active(String user, String featureId){ + return users.contains(user); + } +} diff --git a/src/main/java/fr/maif/features/UserPercentage.java b/src/main/java/fr/maif/features/UserPercentage.java new file mode 100644 index 0000000..bd44419 --- /dev/null +++ b/src/main/java/fr/maif/features/UserPercentage.java @@ -0,0 +1,19 @@ +package fr.maif.features; + +import org.apache.commons.codec.digest.MurmurHash3; + +public class UserPercentage implements ActivationRule { + public Integer percentage; + + public UserPercentage(Integer percentage) { + this.percentage = percentage; + } + + @Override + public boolean active(String user, String feature) { + String toHash = feature + "-" + user; + var bytes = toHash.getBytes(); + long hash = (Math.abs(MurmurHash3.hash32x86(bytes, 0, bytes.length, 42)) % 100) + 1; + return hash <= percentage; + } +} diff --git a/src/main/java/fr/maif/http/HttpRequester.java b/src/main/java/fr/maif/http/HttpRequester.java new file mode 100644 index 0000000..6ed8f6d --- /dev/null +++ b/src/main/java/fr/maif/http/HttpRequester.java @@ -0,0 +1,69 @@ +package fr.maif.http; + +import fr.maif.ClientConfiguration; +import fr.maif.features.Feature; +import fr.maif.requests.FeatureRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.stream.Collectors; + +public final class HttpRequester { + private static final Logger LOGGER = LoggerFactory.getLogger(HttpRequester.class); + + static String url(ClientConfiguration configuration, FeatureRequest request) { + var url = configuration.connectionInformation.url + "/v2/features"; + + var maybeFeatures = request.getFeatures().stream().sorted(String::compareTo).collect(Collectors.joining(",")); + + var params = new TreeMap<>(); + params.put("conditions", true); + if(!maybeFeatures.isBlank()) { + params.put("features", maybeFeatures); + } + request.getContext().ifPresent(ctx -> params.put("context", ctx)); + + Optional.ofNullable(request.getUser()).filter(str -> !str.isBlank()) + .map(user -> params.put("user", user)); + + + String searchPart = params.entrySet().stream() + .map(e -> e.getKey() + "=" + e.getValue()) + .collect(Collectors.joining("&")); + + url = !searchPart.isBlank() ? (url + "?" + searchPart) : url; + + return url; + } + + static CompletableFuture> performCall( + ClientConfiguration configuration, + IzanamiHttpRequest request, + Function> responseMapper + ) { + return configuration.httpClient.apply(request) + // TODO handle error + .thenApply(resp -> responseMapper.apply(resp.body)); + } + public static CompletableFuture>> performRequest( + ClientConfiguration configuration, + FeatureRequest request + ) { + var url = url(configuration, request); + var method = request.getPayload().map(p -> IzanamiHttpRequest.Method.POST).orElse(IzanamiHttpRequest.Method.GET); + var r = new IzanamiHttpRequest(); + r.body = request.getPayload(); + r.method = method; + r.headers = configuration.connectionInformation.headers(); + r.timeout = request.getTimeout().orElseGet(() -> configuration.callTimeout); + r.uri = URI.create(url); + LOGGER.debug("Calling {}", url); + return performCall(configuration, r, ResponseUtils::parseFeatureResponse); + } +} diff --git a/src/main/java/fr/maif/http/IzanamiHttpClient.java b/src/main/java/fr/maif/http/IzanamiHttpClient.java new file mode 100644 index 0000000..f28987d --- /dev/null +++ b/src/main/java/fr/maif/http/IzanamiHttpClient.java @@ -0,0 +1,47 @@ +package fr.maif.http; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +public interface IzanamiHttpClient extends Function> { + class DefaultIzanamiHttpClient implements IzanamiHttpClient { + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultIzanamiHttpClient.class); + public final HttpClient client; + + public DefaultIzanamiHttpClient() { + this.client = HttpClient.newHttpClient(); + } + + @Override + public CompletableFuture apply(IzanamiHttpRequest request) { + var requestBuilder = HttpRequest.newBuilder().timeout(request.timeout); + request.headers.forEach(requestBuilder::setHeader); + + var r = requestBuilder.uri(request.uri); + if(request.method == IzanamiHttpRequest.Method.GET) { + r.GET(); + } else { + r.POST(HttpRequest.BodyPublishers.ofString(request.body.orElse(""))); + } + + return client.sendAsync(r.build(), HttpResponse.BodyHandlers.ofString()) + .thenApply(response -> { + LOGGER.debug("Response for {} : {} (status is {})", request.uri, response.body(), response.statusCode()); + return new IzanamiHttpResponse(response.body(), response.statusCode()); + }).whenComplete((resp, ex) -> { + if(Objects.nonNull(ex)) { + LOGGER.error("Failed to perform http request", ex); + } + }); + } + } + +} + diff --git a/src/main/java/fr/maif/http/IzanamiHttpRequest.java b/src/main/java/fr/maif/http/IzanamiHttpRequest.java new file mode 100644 index 0000000..c35b038 --- /dev/null +++ b/src/main/java/fr/maif/http/IzanamiHttpRequest.java @@ -0,0 +1,20 @@ +package fr.maif.http; + +import java.net.URI; +import java.time.Duration; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +public class IzanamiHttpRequest { + public enum Method { + GET, POST + } + + public URI uri; + public Optional body = Optional.empty(); + public Method method = Method.GET; + public Duration timeout; + public Map headers = new HashMap<>(); +} diff --git a/src/main/java/fr/maif/http/IzanamiHttpResponse.java b/src/main/java/fr/maif/http/IzanamiHttpResponse.java new file mode 100644 index 0000000..b83f010 --- /dev/null +++ b/src/main/java/fr/maif/http/IzanamiHttpResponse.java @@ -0,0 +1,11 @@ +package fr.maif.http; + +public class IzanamiHttpResponse { + public String body; + public int status; + + public IzanamiHttpResponse(String body, int status) { + this.body = body; + this.status = status; + } +} \ No newline at end of file diff --git a/src/main/java/fr/maif/http/ResponseUtils.java b/src/main/java/fr/maif/http/ResponseUtils.java new file mode 100644 index 0000000..2ff680a --- /dev/null +++ b/src/main/java/fr/maif/http/ResponseUtils.java @@ -0,0 +1,202 @@ +package fr.maif.http; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.NullNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import fr.maif.errors.IzanamiException; +import fr.maif.features.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +public final class ResponseUtils { + public static final ObjectMapper mapper = new ObjectMapper(); + public static final Logger LOGGER = LoggerFactory.getLogger(ResponseUtils.class); + static { + mapper.registerModule(new Jdk8Module()); + mapper.registerModule(new JavaTimeModule()); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + public static Result> parseBooleanResponse(String json) { + try { + return Optional.ofNullable(mapper.readValue(json, new TypeReference>() {})) + .map(map -> { + Map result = new HashMap<>(); + map.entrySet() + .forEach(entry -> { + result.put(entry.getKey(), entry.getValue().active); + }); + return result; + }) + .map(Result::new) + .orElseGet(() -> new Result<>("Failed to parse response")); + } catch (JsonMappingException e) { + return new Result<>("Unexpected format received from Izanami"); + } catch (JsonProcessingException e) { + return new Result<>("Invalid JSON received from Izanami"); + } + } + + + public static Result> parseFeatureResponse(String json) { + try { + return Optional.ofNullable(mapper.readValue(json, new TypeReference>() {})) + .map(map -> map.entrySet().stream() + .map(entry -> ResponseUtils.parseFeature(entry.getKey(), entry.getValue())) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toMap(f -> f.id, f -> f)) + ) + .map(Result::new) + .orElseGet(() -> new Result<>("Failed to parse response")); + } catch (JsonMappingException e) { + return new Result<>("Unexpected format received from Izanami"); + } catch (JsonProcessingException e) { + return new Result<>("Invalid JSON received from Izanami"); + } + } + + static FeatureOverload parseOverload(ObjectNode json) { + boolean enabled = json.get("enabled").asBoolean(); + + if (json.has("conditions") && !(json.get("conditions") instanceof NullNode)) { + Set conditions = StreamSupport + .stream(json.get("conditions").spliterator(), false) + .map(ResponseUtils::parseActivationCondition) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toSet()); + return new FeatureOverload.ClassicalOverload(enabled, conditions); + } else { + String name = json.get("wasmConfig").get("name").asText(); + return new FeatureOverload.WasmFeatureOverload(enabled, new FeatureOverload.WasmConfig(name)); + } + } + + static Optional parseFeature(String id, ObjectNode json) { + if(json.isNull()) { + return Optional.empty(); + } + + String name = json.get("name").asText(); + String project = json.get("project").asText(); + boolean active = json.get("active").asBoolean(); + ObjectNode conditions = (ObjectNode) json.get("conditions"); + Map overloads = new HashMap<>(); + try { + var nodeById = mapper.treeToValue(conditions, new TypeReference>(){}); + overloads = nodeById.entrySet().stream() + .map(entry -> { + var overloadJson = entry.getValue(); + return parseFeatureOverload(overloadJson).map(overload -> new AbstractMap.SimpleEntry<>(entry.getKey(), overload)); + }).filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + } catch (JsonProcessingException e) { + throw new IzanamiException(e); + } + + + return Optional.of(new Feature( + id, + name, + project, + active, + overloads + )); + } + + + static Optional parseFeatureOverload(ObjectNode node) { + boolean enabled = node.get("enabled").asBoolean(); + if (node.has("conditions") && !(node.get("conditions") instanceof NullNode)) { + Set conditions = StreamSupport + .stream(node.get("conditions").spliterator(), false) + .map(ResponseUtils::parseActivationCondition) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toSet()); + return Optional.of(new FeatureOverload.ClassicalOverload(enabled, conditions)); + } else if(node.has("wasmConfig")) { + String name = node.get("wasmConfig").get("name").asText(); + + return Optional.of(new FeatureOverload.WasmFeatureOverload(enabled, new FeatureOverload.WasmConfig(name))); + } else { + LOGGER.error("Failed to parse feature overload " + node); + return Optional.empty(); + } + + } + + static Optional parseActivationCondition(JsonNode json) { + if(json.isNull()) { + return Optional.empty(); + } + + var maybePeriod = Optional.ofNullable(json.get("period")).flatMap(ResponseUtils::parseFeaturePeriod); + var maybeRule = Optional.ofNullable(json.get("rule")).flatMap(ResponseUtils::parseRule); + + return Optional.of(new ActivationCondition(maybePeriod.orElse(null), maybeRule.orElse(null))); + } + + static Optional parseFeaturePeriod(JsonNode node) { + if(node.isNull()) { + return Optional.empty(); + } + try { + return Optional.ofNullable(mapper.readValue(node.toPrettyString(), FeaturePeriod.class)); + } catch (JsonProcessingException e) { + return Optional.empty(); + } + } + + static Optional parseRule(JsonNode node) { + if(node.isNull()) { + return Optional.empty(); + } + if(node.has("users")) { + return parseUserList(node); + } else if(node.has("percentage")) { + return parseUserPercentage(node); + } else { + return Optional.empty(); + } + } + + static Optional parseUserList(JsonNode node) { + if(node.isNull()) { + return Optional.empty(); + } + + Set users = StreamSupport.stream(node.get("users").spliterator(), false) + .map(JsonNode::asText).collect(Collectors.toSet()); + + return Optional.of(new UserList(users)); + } + + static Optional parseUserPercentage(JsonNode node) { + if(node.isNull()) { + return Optional.empty(); + } + + var percentage = node.get("percentage").asInt(); + + return Optional.of(new UserPercentage(percentage)); + } + + static class IzanamiServerFeatureResponse { + public Boolean active; + public String name; + } +} diff --git a/src/main/java/fr/maif/http/Result.java b/src/main/java/fr/maif/http/Result.java new file mode 100644 index 0000000..3e3571b --- /dev/null +++ b/src/main/java/fr/maif/http/Result.java @@ -0,0 +1,45 @@ +package fr.maif.http; + +import java.util.Collection; +import java.util.Optional; +import java.util.function.BiFunction; +import java.util.function.Function; + +public class Result { + public final Optional error; + public final R value; + + public Result(String error) { + this.error = Optional.ofNullable(error); + this.value = null; + } + + public Result(R value) { + this.value = value; + this.error = Optional.empty(); + } + + public boolean isError() { + return error.isPresent(); + } + + public Result map(Function mapper) { + return error.map(e -> new Result(e)).orElseGet(() -> new Result<>(mapper.apply(value))); + } + + public static Result merge( + Collection> results, + BiFunction mergeFunction, + O base + ) { + var res = base; + for(Result next: results) { + if(next.isError()) { + return new Result<>(next.error.get()); + } + res = mergeFunction.apply(res, next.value); + } + + return new Result<>(res); + } +} diff --git a/src/main/java/fr/maif/requests/FeatureRequest.java b/src/main/java/fr/maif/requests/FeatureRequest.java new file mode 100644 index 0000000..387a054 --- /dev/null +++ b/src/main/java/fr/maif/requests/FeatureRequest.java @@ -0,0 +1,208 @@ +package fr.maif.requests; + +import fr.maif.errors.IzanamiException; +import fr.maif.FeatureClientErrorStrategy; + +import java.time.Duration; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Data container used to represent query for multiple features + */ +public class FeatureRequest { + Map features = new HashMap<>(); + Optional> errorStrategy = Optional.empty(); + Optional ignoreCache = Optional.empty(); + Optional context = Optional.empty(); + + Optional callTimeout = Optional.empty(); + Optional payload = Optional.empty(); + String user = ""; + + /** + * Create a new multi feature request + * @return a new request + */ + public static FeatureRequest newFeatureRequest() { + return new FeatureRequest(); + } + + public static SingleFeatureRequest newSingleFeatureRequest(String feature) { + return new SingleFeatureRequest(feature); + } + + /** + * Add or update user for this request + * @param val user to use for this request + * @return this request modified with provided user + */ + public FeatureRequest withUser(String val) { + user = val; + return this; + } + + /** + * Add or update error strategy for this request + * @param errorStrategy error strategy to use for this request + * @return this request modified with provided error strategy + */ + public FeatureRequest withErrorStrategy(FeatureClientErrorStrategy errorStrategy) { + this.errorStrategy = Optional.ofNullable(errorStrategy); + return this; + } + + /** + * Add or update http call timeout for this request + * @param timeout http call timeout to use for this request + * @return this request modified with provided http call timeout + */ + public FeatureRequest withCallTimeout(Duration timeout) { + this.callTimeout = Optional.ofNullable(timeout); + return this; + } + + /** + * Add provided features for this request + * @param val features to add to this request + * @return this request modified new features + */ + public FeatureRequest withFeatures(Set val) { + return this.withSpecificFeatures( + val.stream() + .map(SpecificFeatureRequest::feature) + .collect(Collectors.toSet()) + ); + } + + /** + * Add provided features for this request + * @param val features to add to this request + * @return this request modified new features + */ + public FeatureRequest withSpecificFeatures(Set val) { + val.forEach(feature -> { + features.put(feature.feature, feature); + }); + return this; + } + + + /** + * Add provided features for this request + * @param features features to add to this request + * @return this request modified new features + */ + public FeatureRequest withFeatures(String... features) { + return this.withSpecificFeatures(Arrays.stream(features) + .map(SpecificFeatureRequest::feature) + .collect(Collectors.toSet())); + } + + /** + * Add provided features for this request + * @param features features to add to this request + * @return this request modified new features + */ + public FeatureRequest withFeatures(SpecificFeatureRequest... features) { + return this.withSpecificFeatures(Arrays.stream(features).collect(Collectors.toSet())); + } + + /** + * Add provided features for this request + * @param features features to add to this request + * @return this request modified new features + */ + public FeatureRequest withFeature(SpecificFeatureRequest features) { + this.features.put(features.feature, features); + return this; + } + + /** + * Add provided feature for this request + * @param feature features to add to this request + * @return this request modified new feature + */ + public FeatureRequest withFeature(String feature) { + return this.withFeature(SpecificFeatureRequest.feature(feature)); + } + + /** + * Whether this request should ignore cache + * @param ignoreCache indicate if this feature should ignore cache + * @return this request modified with cache use indication + */ + public FeatureRequest ignoreCache(boolean ignoreCache) { + this.ignoreCache = Optional.of(ignoreCache); + return this; + } + + /** + * Context to use for this request + * @param context context to use + * @return this request modified with given context + */ + public FeatureRequest withContext(String context) { + this.context = Optional.ofNullable(context); + return this; + } + + public FeatureRequest withPayload(String payload) { + this.payload = Optional.ofNullable(payload); + return this; + } + + Optional isCacheIgnoredFor(String feature) { + if(!features.containsKey(feature)) { + throw new IzanamiException("Feature " + feature + " is not present in request."); + } + var specificFeature = features.get(feature); + return specificFeature.ignoreCache + .or(() -> this.ignoreCache); + } + + Optional> errorStrategyFor(String feature) { + if(!features.containsKey(feature)) { + throw new IzanamiException("Feature " + feature + " is not present in request."); + } + var specificFeature = features.get(feature); + return specificFeature.errorStrategy + .or(() -> this.errorStrategy); + } + + /** + * Feature ids for this request + * @return String feature ids for this request + */ + public Set getFeatures() { + return features.keySet(); + } + + /** + * User for this request + * @return user for this request + */ + public String getUser() { + return user; + } + + /** + * Context for this request + * @return An optional indicating context used for this request (if any) + */ + public Optional getContext() { + return context; + } + + /** + * Http call timeout for this request + * @return An optional indicating http call timeout to use for this request (if any) + */ + public Optional getTimeout() { + return callTimeout; + } + + public Optional getPayload() { + return payload; + } +} diff --git a/src/main/java/fr/maif/requests/FeatureService.java b/src/main/java/fr/maif/requests/FeatureService.java new file mode 100644 index 0000000..00c862f --- /dev/null +++ b/src/main/java/fr/maif/requests/FeatureService.java @@ -0,0 +1,159 @@ +package fr.maif.requests; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import fr.maif.ClientConfiguration; +import fr.maif.errors.IzanamiError; +import fr.maif.features.Feature; +import fr.maif.http.HttpRequester; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import static fr.maif.requests.FeatureRequest.newFeatureRequest; + +public class FeatureService { + protected ClientConfiguration configuration; + private static final Logger LOGGER = LoggerFactory.getLogger(FeatureService.class); + private final Cache cache; + + public FeatureService(ClientConfiguration configuration) { + this.configuration = configuration; + this.cache = Caffeine.newBuilder() + .build(); + + + if(configuration.cacheConfiguration.enabled) { + ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + scheduler.scheduleAtFixedRate( + this::refreshCache, + 0, + configuration.cacheConfiguration.refreshInterval.getSeconds(), TimeUnit.SECONDS + ); + } + } + + private void putValuesInCache(Map features) { + features.forEach(cache::put); + } + + private void refreshCache() { + Set features = cache.asMap().keySet(); + LOGGER.debug("Refreshing cache for {}", String.join(",", features)); + if(features.isEmpty()) { + return; + } + var request = new FeatureRequest().withFeatures(features); + + HttpRequester + .performRequest(configuration, request) + .thenAccept(result -> { + if(!result.isError()) { + LOGGER.debug("Received following features for cache refresh {}", String.join("," + result.value.entrySet())); + cache.invalidateAll(); + putValuesInCache(result.value); + } else { + LOGGER.error("Failed to refresh cache : {}", result.error.get()); + } + }); + } + + public CompletableFuture> featureStates( + FeatureRequest request + ) { + LOGGER.debug("Feature activation request for {}", String.join(",", request.features.keySet())); + Set missingFeatures = new HashSet<>(); + Map activation = new HashMap<>(); + + request.features.values() + .forEach(f -> { + boolean shouldIgnoreCache = request.isCacheIgnoredFor(f.feature) + .orElseGet(() -> !configuration.cacheConfiguration.enabled); + if(shouldIgnoreCache) { + missingFeatures.add(f); + } else { + Optional maybeActivation = Optional.ofNullable(cache.getIfPresent(f.feature)) + .flatMap(cachedFeature -> cachedFeature.active(request.context.orElse(null), request.user)); + maybeActivation.ifPresentOrElse(active -> activation.put(f.feature, active), () -> missingFeatures.add(f)); + } + }); + + if(missingFeatures.isEmpty()) { + return CompletableFuture + .completedFuture(activation); + } else { + var missingRequest = newFeatureRequest().withSpecificFeatures(missingFeatures) + .withErrorStrategy(request.errorStrategy.orElse(null)) + .withCallTimeout(request.getTimeout().orElse(null)) + .withUser(request.user) + .withContext(request.context.orElse(null)) + .withPayload(request.payload.orElse(null)); + return HttpRequester.performRequest(configuration, missingRequest) + .thenApply(featureResponse -> { + if(featureResponse.isError()) { + String errorMsg = featureResponse.error.get(); + LOGGER.error("Failed to retrieve features : {}", errorMsg); + missingFeatures.forEach(f -> { + var errorStrategy = missingRequest.errorStrategyFor(f.feature).orElseGet(() -> configuration.errorStrategy); + if(!errorStrategy.lastKnownFallbackAllowed) { + activation.put(f.feature, errorStrategy.handleError(new IzanamiError(errorMsg)).join()); + } else { + Boolean active = Optional.ofNullable(cache.getIfPresent(f.feature)) + .flatMap(feat -> feat.active(missingRequest.context.orElse(null), missingRequest.user)) + .orElseGet(() -> errorStrategy.handleError(new IzanamiError(errorMsg)).join()); + activation.put(f.feature, active); + } + }); + } else { + Map featuresById = featureResponse.value; + missingFeatures.forEach(f -> { + if(featuresById.containsKey(f.feature)) { + var feature = featuresById.get(f.feature); + cache.put(f.feature, feature); + activation.put(f.feature, feature.active); + } else { + // TODO deduplicate this + var errorStrategy = request.errorStrategyFor(f.feature).orElseGet(() -> configuration.errorStrategy); + String errorMessage = "Missing feature in Izanami response : " + f.feature +". Either this feature has been deleted or your key is not authorized for it."; + if(!errorStrategy.lastKnownFallbackAllowed) { + activation.put(f.feature, errorStrategy.handleError(new IzanamiError(errorMessage)).join()); + } else { + Boolean active = Optional.ofNullable(cache.getIfPresent(f.feature)) + .flatMap(feat -> feat.active(request.context.orElse(null), request.user)) + .orElseGet(() -> errorStrategy.handleError(new IzanamiError(errorMessage)).join()); + activation.put(f.feature, active); + } + } + }); + } + return activation; + }).exceptionally(ex -> { + LOGGER.error("Failed to query remote Izanami", ex); + missingFeatures.forEach(f -> { + // TODO deduplicate this + var errorStrategy = request.errorStrategyFor(f.feature).orElseGet(() -> configuration.errorStrategy); + String errorMessage = "Missing feature in Izanami response : " + f.feature +". Either this feature has been deleted or your key is not authorized for it."; + if(!errorStrategy.lastKnownFallbackAllowed) { + activation.put(f.feature, errorStrategy.handleError(new IzanamiError(errorMessage)).join()); + } else { + Boolean active = Optional.ofNullable(cache.getIfPresent(f.feature)) + .flatMap(feat -> feat.active(request.context.orElse(null), request.user)) + .orElseGet(() -> errorStrategy.handleError(new IzanamiError(errorMessage)).join()); + activation.put(f.feature, active); + } + }); + return activation; + }); + } + } + + public CompletableFuture featureStates(SingleFeatureRequest request) { + return featureStates(request.toActivationRequest()) + .thenApply(resp -> resp.get(request.feature)); + } +} diff --git a/src/main/java/fr/maif/requests/IzanamiConnectionInformation.java b/src/main/java/fr/maif/requests/IzanamiConnectionInformation.java new file mode 100644 index 0000000..7323ef7 --- /dev/null +++ b/src/main/java/fr/maif/requests/IzanamiConnectionInformation.java @@ -0,0 +1,84 @@ +package fr.maif.requests; + +import java.util.Map; + +/** + * Class that contains everything needed to establish connection with Izanami. + */ +public class IzanamiConnectionInformation { + public final String clientId; + public final String clientSecret; + public final String url; + + private IzanamiConnectionInformation(String url, String clientId, String clientSecret) { + this.clientId = clientId; + this.clientSecret = clientSecret; + this.url = url; + } + + /** + * Create an empty connectionInformation + * @return a new EmptyConnectionInformation + */ + public static EmptyConnectionInformation connectionInformation() { + return new EmptyConnectionInformation(); + } + + /** + * Provide headers to use for remote izanami call + * @return headers + */ + public Map headers() { + return Map.of( + "Izanami-Client-Id", clientId, + "Izanami-Client-Secret", clientSecret + ); + } + + public static class EmptyConnectionInformation { + /** + * Remote izanami URL + * @param url url of remote izanami, it should include "/api", for instance "https://my-remote-izanami/api" + * @return a new UrlConnectionInformation that contains given url + */ + public UrlConnectionInformation withUrl(String url) { + return new UrlConnectionInformation(url); + } + } + + public static class UrlConnectionInformation { + public final String url; + + private UrlConnectionInformation(String url) { + this.url = url; + } + + /** + * Client id to use for remote izanami requests + * @param clientId client id + * @return a new ClientIdConnectionInformation that contains given url and client id + */ + public ClientIdConnectionInformation withClientId(String clientId) { + return new ClientIdConnectionInformation(url, clientId); + } + } + + public static class ClientIdConnectionInformation { + public final String url; + public final String clientId; + + private ClientIdConnectionInformation(String url, String clientId) { + this.clientId = clientId; + this.url = url; + } + + /** + * Client secret to use for remote izanami requests + * @param clientSecret client secret + * @return a new IzanamiConnectionInformation that contains given url, client id and client secret + */ + public IzanamiConnectionInformation withClientSecret(String clientSecret) { + return new IzanamiConnectionInformation(url, clientId, clientSecret); + } + } +} diff --git a/src/main/java/fr/maif/requests/SingleFeatureRequest.java b/src/main/java/fr/maif/requests/SingleFeatureRequest.java new file mode 100644 index 0000000..de7a396 --- /dev/null +++ b/src/main/java/fr/maif/requests/SingleFeatureRequest.java @@ -0,0 +1,77 @@ +package fr.maif.requests; + +import fr.maif.FeatureClientErrorStrategy; + +/** + * This class is a data container used to represent query for a single feature + */ +public class SingleFeatureRequest { + private FeatureRequest request = FeatureRequest.newFeatureRequest(); + String feature; + + /** + * Constructor + * @param feature feature id to query + */ + public SingleFeatureRequest(String feature) { + this.feature = feature; + } + + /** + * Create a new single feature request with provided feature id + * @param feature feature id to query + * @return a new SingleFeatureRequest initialized with given id + */ + public static SingleFeatureRequest newSingleFeatureRequest(String feature) { + return new SingleFeatureRequest(feature); + } + + /** + * Add or update user for this request + * @param val user to use for this request + * @return this request modified with provided user + */ + public SingleFeatureRequest withUser(String val) { + request.user = val; + return this; + } + + /** + * Add or update error strategy for this request + * @param errorStrategy error strategy to use for this request + * @return this request modified with provided error strategy + */ + public SingleFeatureRequest withErrorStrategy(FeatureClientErrorStrategy errorStrategy) { + request.withErrorStrategy(errorStrategy); + return this; + } + + /** + * Context to use for this request + * @param context context to use + * @return this request modified with given context + */ + public SingleFeatureRequest withContext(String context) { + request.withContext(context); + return this; + } + + public SingleFeatureRequest withPayload(String payload) { + request.withPayload(payload); + return this; + } + + /** + * Whether this request should ignore cache + * @param shouldIgnoreCache indicate if this feature should ignore cache + * @return this request modified with cache use indication + */ + public SingleFeatureRequest ignoreCache(boolean shouldIgnoreCache) { + request.ignoreCache(shouldIgnoreCache); + return this; + } + + public FeatureRequest toActivationRequest() { + return request.withFeatures(feature); + } +} diff --git a/src/main/java/fr/maif/requests/SpecificFeatureRequest.java b/src/main/java/fr/maif/requests/SpecificFeatureRequest.java new file mode 100644 index 0000000..400a7be --- /dev/null +++ b/src/main/java/fr/maif/requests/SpecificFeatureRequest.java @@ -0,0 +1,48 @@ +package fr.maif.requests; + +import fr.maif.FeatureClientErrorStrategy; + +import java.util.Optional; + +/** + * Data container that can be used to specify a per feature error/cache strategy for a given query. + */ +public class SpecificFeatureRequest { + public final String feature; + public Optional ignoreCache = Optional.empty(); + public Optional> errorStrategy = Optional.empty(); + + private SpecificFeatureRequest(String feature) { + this.feature = feature; + } + + /** + * Feature id to query + * @param feature feature id + * @return new specific feature request with provided id + */ + public static SpecificFeatureRequest feature(String feature) { + return new SpecificFeatureRequest(feature); + } + + /** + * Whether cache should be ignored this feature + * @param ignoreCache if cache should be ignored + * @return this object updated with given cache strategy + */ + public SpecificFeatureRequest ignoreCache(boolean ignoreCache) { + this.ignoreCache = Optional.of(ignoreCache); + return this; + } + + /** + * Error strategy to use for this query + * @param errorStrategy error strategy to use + * @return this object updated with given error strategy + */ + public SpecificFeatureRequest withErrorStrategy(FeatureClientErrorStrategy errorStrategy) { + this.errorStrategy = Optional.ofNullable(errorStrategy); + return this; + } + +} diff --git a/src/main/resources/.gitkeep b/src/main/resources/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/test/java/fr/maif/IzanamiClientTest.java b/src/test/java/fr/maif/IzanamiClientTest.java new file mode 100644 index 0000000..619532b --- /dev/null +++ b/src/test/java/fr/maif/IzanamiClientTest.java @@ -0,0 +1,1407 @@ +package fr.maif; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import fr.maif.errors.IzanamiException; +import fr.maif.requests.IzanamiConnectionInformation; +import fr.maif.requests.SpecificFeatureRequest; +import org.junit.jupiter.api.*; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicBoolean; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; +import static fr.maif.Mocks.*; +import static fr.maif.requests.IzanamiConnectionInformation.connectionInformation; +import static fr.maif.requests.FeatureRequest.newFeatureRequest; +import static fr.maif.requests.SingleFeatureRequest.newSingleFeatureRequest; +import static fr.maif.FeatureClientErrorStrategy.*; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.awaitility.Awaitility.await; + +public class IzanamiClientTest { + static WireMockServer mockServer; + + @BeforeAll + public static void init() { + mockServer = new WireMockServer(options().port(9999)); + mockServer.start(); + } + + @BeforeEach + public void beforeEach() { + mockServer.setGlobalFixedDelay(10); + } + + @AfterAll + public static void tearDown() { + mockServer.stop(); + } + + @AfterEach + public void resetMocks() { + mockServer.resetAll(); + } + + @Test + public void should_recompute_feature_locally_when_requested_with_different_parameters() throws InterruptedException { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", false).withOverload(overload(true).withCondition(condition(true).withRule(userListRule("foo")))); + String stub = newResponse().withFeature(id, featureStub).toJson(); + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + + mockServer.stubFor(WireMock.get("/api/v2/features?conditions=true&features=" + id + "&user=bar") + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(stub) + ) + ); + + var client = IzanamiClient.newBuilder( + IzanamiConnectionInformation + .connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ) + .withCacheConfiguration(FeatureCacheConfiguration + .newBuilder() + .enabled(true) + .build() + ) + .build(); + + var result = client.checkFeatureActivation( + newSingleFeatureRequest(id) + .withUser("bar") + ).join(); + assertThat(result).isFalse(); + + + mockServer.resetAll(); + + result = client.checkFeatureActivation( + newSingleFeatureRequest(id) + .withUser("foo") + ).join(); + assertThat(result).isTrue(); + + result = client.checkFeatureActivation( + newSingleFeatureRequest(id) + .withUser("bar") + ).join(); + assertThat(result).isFalse(); + } + + @Test + public void should_allow_to_bypass_cache() throws InterruptedException { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", false).withOverload(overload(false)); + var response = newResponse().withFeature(id, featureStub); + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + + mockServer.stubFor(WireMock.get("/api/v2/features?conditions=true&features=" + id + "&user=bar") + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + )); + + var client = IzanamiClient.newBuilder( + IzanamiConnectionInformation.connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ) + .withCacheConfiguration(FeatureCacheConfiguration.newBuilder().enabled(true).build()) + .build(); + + var result = client.checkFeatureActivation( + newSingleFeatureRequest("ae5dd05d-4e90-4ce7-bee7-3751750fdeaa") + .withUser("bar")).join(); + assertThat(result).isFalse(); + + featureStub.active = true; + + mockServer.stubFor(WireMock.get("/api/v2/features?conditions=true&features=ae5dd05d-4e90-4ce7-bee7-3751750fdeaa&user=bar") + .withHeader("Izanami-Client-Id", equalTo("87mpqvd86tskt43h")) + .withHeader("Izanami-Client-Secret", equalTo("kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg")) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + )); + + result = client.checkFeatureActivation( + newSingleFeatureRequest("ae5dd05d-4e90-4ce7-bee7-3751750fdeaa") + .withUser("bar") + .ignoreCache(true) + ).join(); + assertThat(result).isTrue(); + } + + @Test + public void cache_bypass_should_update_cache() throws InterruptedException { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", false).withOverload(overload(false)); + var response = newResponse().withFeature(id, featureStub); + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + + mockServer.stubFor(WireMock.get("/api/v2/features?conditions=true&features=" + id) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ).build(); + + var result = client.checkFeatureActivation( + newSingleFeatureRequest(id)).join(); + + assertThat(result).isFalse(); + + featureStub.active(true).withOverload(overload(true)); + + mockServer.stubFor(WireMock.get("/api/v2/features?conditions=true&features=" + id) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + )); + + + result = client.checkFeatureActivation( + newSingleFeatureRequest(id) + .ignoreCache(true) + ).join(); + assertThat(result).isTrue(); + + mockServer.resetAll(); + + result = client.checkFeatureActivation( + newSingleFeatureRequest(id) + ).join(); + assertThat(result).isTrue(); + } + + @Test + public void should_not_use_cache_for_script_feature() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", true).withOverload(overload(true).withScript("foo")); + var response = newResponse().withFeature(id, featureStub); + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + + mockServer.stubFor(WireMock.get("/api/v2/features?conditions=true&features=" + id) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ).build(); + + var result = client.checkFeatureActivation( + newSingleFeatureRequest(id)).join(); + + assertThat(result).isTrue(); + + featureStub.active = false; + + mockServer.stubFor(WireMock.get("/api/v2/features?conditions=true&features=" + id) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + result = client.checkFeatureActivation( + newSingleFeatureRequest(id)).join(); + + assertThat(result).isFalse(); + } + + + @Test + public void should_not_use_cache_if_disabled() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); + var response = newResponse().withFeature(id, featureStub); + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String url = "/api/v2/features?conditions=true&features=" + id; + + mockServer.stubFor(WireMock.get(url) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(false).build() + ).build(); + + var result = client.checkFeatureActivation( + newSingleFeatureRequest(id)).join(); + + assertThat(result).isTrue(); + + featureStub.active = false; + + mockServer.stubFor(WireMock.get("/api/v2/features?conditions=true&features=" + id) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + result = client.checkFeatureActivation( + newSingleFeatureRequest(id)).join(); + + assertThat(result).isFalse(); + var count = mockServer.countRequestsMatching(getRequestedFor(urlEqualTo(url)).build()).getCount(); + assertThat(count).isEqualTo(2); + + } + + + @Test + public void should_use_cache_even_if_disabled_when_query_fails() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); + var response = newResponse().withFeature(id, featureStub); + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String url = "/api/v2/features?conditions=true&features=" + id; + + mockServer.stubFor(WireMock.get(url) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(false).build() + ).build(); + + var result = client.checkFeatureActivation( + newSingleFeatureRequest(id)).join(); + + assertThat(result).isTrue(); + + mockServer.resetAll(); + + result = client.checkFeatureActivation( + newSingleFeatureRequest(id)).join(); + + assertThat(result).isTrue(); + } + + @Test + public void should_use_cache_even_if_ignored_when_query_fails() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); + var response = newResponse().withFeature(id, featureStub); + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String url = "/api/v2/features?conditions=true&features=" + id; + + mockServer.stubFor(WireMock.get(url) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ).build(); + + var result = client.checkFeatureActivation( + newSingleFeatureRequest(id)).join(); + + assertThat(result).isTrue(); + + mockServer.resetAll(); + + result = client.checkFeatureActivation( + newSingleFeatureRequest(id).ignoreCache(true)).join(); + + assertThat(result).isTrue(); + } + + @Test + public void should_not_use_cache_on_failed_query_if_specified() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); + var response = newResponse().withFeature(id, featureStub); + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String url = "/api/v2/features?conditions=true&features=" + id; + + mockServer.stubFor(WireMock.get(url) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ).build(); + + var result = client.checkFeatureActivation( + newSingleFeatureRequest(id)).join(); + + assertThat(result).isTrue(); + + mockServer.resetAll(); + + assertThatThrownBy(() -> { + client.checkFeatureActivation( + newSingleFeatureRequest(id) + .ignoreCache(true) + .withErrorStrategy(failStrategy().fallbackOnLastKnownStrategy(false)) + ).join(); + }); + } + + @Test + public void should_prioritize_feature_cache_instruction_over_query() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); + var response = newResponse().withFeature(id, featureStub); + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String url = "/api/v2/features?conditions=true&features=" + id; + + mockServer.stubFor(WireMock.get(url) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ).build(); + + var result = client.checkFeatureActivation( + newSingleFeatureRequest(id) + ).join(); + assertThat(result).isTrue(); + + assertThat(featureStub.active).isTrue(); + featureStub.active = false; + + mockServer.resetAll(); + + mockServer.stubFor(WireMock.get(url) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + var multipleResult = client.checkFeatureActivations( + newFeatureRequest() + .withFeatures( + SpecificFeatureRequest.feature(id).ignoreCache(true) + ).ignoreCache(false) + ).join(); + + assertThat(multipleResult.get(id)).isFalse(); + } + + @Test + public void fail_strategy_should_throw_an_exception_when_needed() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ).withErrorStrategy(failStrategy()) + .build(); + + assertThatThrownBy(() -> { + client.checkFeatureActivation( + newSingleFeatureRequest(id) + ).join(); + }).isInstanceOf(CompletionException.class).hasCauseInstanceOf(IzanamiException.class); + } + + @Test + public void default_value_strategy_should_return_given_value() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ).withErrorStrategy(defaultValueStrategy(true)) + .build(); + + var result = client.checkFeatureActivation( + newSingleFeatureRequest(id) + ).join(); + + assertThat(result).isTrue(); + } + + @Test + public void callback_strategy_should_return_callback_value() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + AtomicBoolean callbackCalled = new AtomicBoolean(false); + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ).withErrorStrategy(callbackStrategy(err -> { + callbackCalled.set(true); + return CompletableFuture.completedFuture(true); + })) + .build(); + + var result = client.checkFeatureActivation( + newSingleFeatureRequest(id) + ).join(); + + assertThat(result).isTrue(); + assertThat(callbackCalled.get()).isTrue(); + } + + + @Test + public void feature_error_strategy_should_prevail_over_query() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ) + .build(); + + var result = client.checkFeatureActivations( + newFeatureRequest() + .withFeatures( + SpecificFeatureRequest + .feature(id) + .withErrorStrategy(defaultValueStrategy(true)) + ) + .withErrorStrategy(failStrategy()) + ).join(); + + assertThat(result.get(id)).isTrue(); + } + + @Test + public void feature_error_strategy_should_prevail_over_global() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ).withErrorStrategy(failStrategy()) + .build(); + + var result = client.checkFeatureActivations( + newFeatureRequest() + .withFeatures( + SpecificFeatureRequest + .feature(id) + .withErrorStrategy(defaultValueStrategy(true)) + ) + ).join(); + + assertThat(result.get(id)).isTrue(); + } + + @Test + public void query_error_strategy_should_prevail_over_global() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ).withErrorStrategy(failStrategy()) + .build(); + + var result = client.checkFeatureActivations( + newFeatureRequest() + .withFeatures(id) + .withErrorStrategy(defaultValueStrategy(true)) + ).join(); + + assertThat(result.get(id)).isTrue(); + } + + @Test + public void fail_strategy_should_throw_for_single_feature_request() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ).withErrorStrategy(failStrategy()) + .build(); + + assertThatThrownBy(() -> { + client.checkFeatureActivation( + newSingleFeatureRequest(id) + ).join(); + }).hasCauseInstanceOf(IzanamiException.class); + } + + @Test + public void fail_strategy_should_throw_for_multiple_feature_query() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ).withErrorStrategy(failStrategy()) + .build(); + + assertThatThrownBy(() -> { + client.checkFeatureActivations( + newFeatureRequest().withFeatures(id) + ).join(); + }).hasCauseInstanceOf(IzanamiException.class); + } + + @Test + public void should_return_all_features_activation_for_multi_feature_query() { + String id1 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + String id2 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeae"; + var featureStub1 = Mocks.feature("bar", true).withOverload(overload(true)); + var featureStub2 = Mocks.feature("bar", false).withOverload(overload(true)); + var response = newResponse().withFeature(id1, featureStub1).withFeature(id2, featureStub2); + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String url = "/api/v2/features?conditions=true&features=" + id1 + "," + id2; + String url2 = "/api/v2/features?conditions=true&features=" + id2 + "," + id1; + + mockServer.stubFor(WireMock.get(url) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + mockServer.stubFor(WireMock.get(url2) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ).build(); + + var result = client.checkFeatureActivations( + newFeatureRequest().withFeatures(id1, id2) + ).join(); + + assertThat(result.get(id1)).isTrue(); + assertThat(result.get(id2)).isFalse(); + } + + + @Test + public void should_use_error_strategy_for_missing_feature_in_multi_feature_query() { + String id1 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + String id2 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeae"; + var featureStub1 = Mocks.feature("bar", true).withOverload(overload(true)); + var response = newResponse().withFeature(id1, featureStub1); + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String url = "/api/v2/features?conditions=true&features=" + id1 + "," + id2; + String url2 = "/api/v2/features?conditions=true&features=" + id2 + "," + id1; + + mockServer.stubFor(WireMock.get(url) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + mockServer.stubFor(WireMock.get(url2) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ).withErrorStrategy(defaultValueStrategy(false)) + .build(); + + var result = client.checkFeatureActivations( + newFeatureRequest().withFeatures(id1, id2) + ).join(); + + assertThat(result.get(id1)).isTrue(); + assertThat(result.get(id2)).isFalse(); + } + + + @Test + public void should_use_individual_strategies_when_query_fails_if_defined() { + String id1 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + String id2 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeae"; + String id3 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeao"; + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String url = "/api/v2/features?conditions=true&features=" + id1 + "," + id2 + "," + id3; + String url2 = "/api/v2/features?conditions=true&features=" + id1 + "," + id3 + "," + id2; + String url3 = "/api/v2/features?conditions=true&features=" + id2 + "," + id1 + "," + id3; + String url4 = "/api/v2/features?conditions=true&features=" + id2 + "," + id3 + "," + id1; + String url5 = "/api/v2/features?conditions=true&features=" + id3 + "," + id1 + "," + id2; + String url6 = "/api/v2/features?conditions=true&features=" + id3 + "," + id2 + "," + id1; + + List.of(url, url2, url3, url4, url5, url6).forEach(u -> { + mockServer.stubFor(WireMock.get(u) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.serverError() + .withBody("foo") + ) + ); + }); + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ).withErrorStrategy(failStrategy()) + .build(); + + var result = client.checkFeatureActivations( + newFeatureRequest() + .withFeatures( + SpecificFeatureRequest.feature(id1).withErrorStrategy(defaultValueStrategy(true)), + SpecificFeatureRequest.feature(id2).withErrorStrategy(callbackStrategy(err -> CompletableFuture.completedFuture(false))), + SpecificFeatureRequest.feature(id3).withErrorStrategy(nullValueStrategy()) + ) + ).join(); + + assertThat(result.get(id1)).isTrue(); + assertThat(result.get(id2)).isFalse(); + assertThat(result.get(id3)).isNull(); + } + + @Test + public void should_return_activation_status_for_given_context() { + String id1 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + String id2 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeae"; + var featureStub1 = Mocks.feature("bar", true).withOverload(overload(true)).withOverload("foo", overload(false)); + var featureStub2 = Mocks.feature("bar", true).withOverload(overload(false)).withOverload("foo", overload(true)); + var response = newResponse().withFeature(id1, featureStub1).withFeature(id2, featureStub2); + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String url = "/api/v2/features"; + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("features", equalTo(id1 + "," + id2)) + .withQueryParam("context", absent()) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + featureStub1.active = false; + featureStub2.active = true; + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("features", equalTo(id1 + "," + id2)) + .withQueryParam("context", equalTo("foo")) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ).build(); + + var result = client.checkFeatureActivations( + newFeatureRequest().withFeatures(id1, id2).withContext("foo") + ).join(); + + assertThat(result.get(id1)).isFalse(); + assertThat(result.get(id2)).isTrue(); + + // Test cache + result = client.checkFeatureActivations( + newFeatureRequest().withFeatures(id1, id2).withContext("foo") + ).join(); + + assertThat(result.get(id1)).isFalse(); + assertThat(result.get(id2)).isTrue(); + + result = client.checkFeatureActivations( + newFeatureRequest().withFeatures(id1, id2) + ).join(); + + assertThat(result.get(id1)).isTrue(); + assertThat(result.get(id2)).isFalse(); + } + + + @Test + public void should_handle_context_hierarchy_correctly() { + String id1 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + String id2 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeae"; + String id3 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeao"; + var featureStub1 = Mocks.feature("bar1", true).withOverload(overload(true)); + var featureStub2 = Mocks.feature("bar2", false).withOverload(overload(true)).withOverload("foo", overload(true)).withOverload("foo/bar", overload(false)); + var featureStub3 = Mocks.feature("bar3", true).withOverload(overload(false)).withOverload("foo", overload(true)); + var response = newResponse().withFeature(id1, featureStub1).withFeature(id2, featureStub2).withFeature(id3, featureStub3); + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String url = "/api/v2/features"; + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("features", equalTo(id1 + "," + id2 + "," + id3)) + .withQueryParam("context", equalTo("foo/bar")) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ).build(); + + var result = client.checkFeatureActivations( + newFeatureRequest().withFeatures(id1, id2, id3).withContext("foo/bar") + ).join(); + + assertThat(result.get(id1)).isTrue(); + assertThat(result.get(id2)).isFalse(); + assertThat(result.get(id3)).isTrue(); + + // Test cache + result = client.checkFeatureActivations( + newFeatureRequest().withFeatures(id1, id2, id3).withContext("foo/bar") + ).join(); + + assertThat(result.get(id1)).isTrue(); + assertThat(result.get(id2)).isFalse(); + assertThat(result.get(id3)).isTrue(); + } + + + @Test + public void empty_multiple_query_should_return_empty_map() { + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId("foo") + .withClientSecret("bar") + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ).build(); + var result = client.checkFeatureActivations(newFeatureRequest()).join(); + + assertThat(result.size()).isEqualTo(0); + } + + @Test + public void empty_single_query_should_throw() { + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId("foo") + .withClientSecret("bar") + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ).build(); + assertThatThrownBy(() -> client.checkFeatureActivation(newSingleFeatureRequest(null)).join()) + .isInstanceOf(NullPointerException.class); + } + + @Test + public void single_queries_with_cache_ignore_should_ignore_cache() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar", true).withOverload(overload(true)); + var response = newResponse().withFeature(id, featureStub); + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String url = "/api/v2/features?conditions=true&features=" + id; + + mockServer.stubFor(WireMock.get(url) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ).build(); + + + var result = client.checkFeatureActivation(newSingleFeatureRequest(id)).join(); + + assertThat(result).isTrue(); + + featureStub.active = false; + mockServer.stubFor(WireMock.get(url) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + result = client.checkFeatureActivation(newSingleFeatureRequest(id)).join(); + assertThat(result).isTrue(); + + result = client.checkFeatureActivation(newSingleFeatureRequest(id).ignoreCache(true)).join(); + assertThat(result).isFalse(); + + } + + @Test + public void multiple_queries_with_cache_ignore_should_ignore_cache() { + String id1 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + String id2 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeae"; + var featureStub1 = Mocks.feature("bar1", true).withOverload(overload(true)); + var featureStub2 = Mocks.feature("bar2", false).withOverload(overload(false)); + var response = newResponse().withFeature(id1, featureStub1).withFeature(id2, featureStub2); + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String url = "/api/v2/features"; + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("features", equalTo(id1 + "," + id2)) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ).build(); + + + var result = client.checkFeatureActivations(newFeatureRequest().withFeatures(id1, id2)).join(); + + assertThat(result.get(id1)).isTrue(); + assertThat(result.get(id2)).isFalse(); + + featureStub1.active = false; + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("features", equalTo(id1 + "," + id2)) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + result = client.checkFeatureActivations(newFeatureRequest().withFeatures(id1, id2)).join(); + assertThat(result.get(id1)).isTrue(); + assertThat(result.get(id2)).isFalse(); + + result = client.checkFeatureActivations(newFeatureRequest().withFeatures(id1, id2).ignoreCache(true)).join(); + assertThat(result.get(id1)).isFalse(); + assertThat(result.get(id2)).isFalse(); + } + + @Test + public void query_timeout_should_apply() { + String id1 = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub1 = Mocks.feature("bar1", true).withOverload(overload(true)); + var response = newResponse().withFeature(id1, featureStub1); + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String url = "/api/v2/features"; + + mockServer.setGlobalFixedDelay(5000); + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("features", equalTo(id1)) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder().enabled(true).build() + ).withCallTimeout(Duration.ofSeconds(2L)) + .withErrorStrategy(defaultValueStrategy(false)) + .build(); + + + var result = client.checkFeatureActivations(newFeatureRequest().withFeatures(id1)).join(); + + assertThat(result.get(id1)).isFalse(); + } + + @Test + public void cache_should_be_refreshed_at_specified_periods() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar1", true).withOverload(overload(true)); + var response = newResponse().withFeature(id, featureStub); + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String url = "/api/v2/features"; + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("features", equalTo(id)) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder() + .withRefreshInterval(Duration.ofSeconds(2L)) + .enabled(true) + .build() + ) + .build(); + + var result = client.checkFeatureActivation(newSingleFeatureRequest(id)).join(); + assertThat(result).isTrue(); + + featureStub.conditions.put("", overload(false)); + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("features", equalTo(id)) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + result = client.checkFeatureActivation(newSingleFeatureRequest(id)).join(); + assertThat(result).isTrue(); + + await().atMost(5, SECONDS).until(() -> { + var localResult = client.checkFeatureActivation(newSingleFeatureRequest(id)).join(); + return !localResult; + }); + + } + + @Test + public void cache_should_not_be_cleared_if_refresh_fails() throws InterruptedException { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar1", true).withOverload(overload(true)); + var response = newResponse().withFeature(id, featureStub); + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String url = "/api/v2/features"; + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("features", equalTo(id)) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder() + .withRefreshInterval(Duration.ofSeconds(2L)) + .enabled(true) + .build() + ) + .build(); + + var result = client.checkFeatureActivation(newSingleFeatureRequest(id)).join(); + assertThat(result).isTrue(); + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("features", equalTo(id)) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.serverError()) + ); + result = client.checkFeatureActivation(newSingleFeatureRequest(id)).join(); + assertThat(result).isTrue(); + + Thread.sleep(10_000); + + var localResult = client.checkFeatureActivation(newSingleFeatureRequest(id).withErrorStrategy(defaultValueStrategy(false))).join(); + assertThat(localResult).isTrue(); + + } + + @Test + public void cache_should_not_be_cleared_if_refresh_timeout() throws InterruptedException { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar1", true).withOverload(overload(true)); + var response = newResponse().withFeature(id, featureStub); + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String url = "/api/v2/features"; + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("features", equalTo(id)) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder() + .withRefreshInterval(Duration.ofSeconds(2L)) + .enabled(true) + .build() + ) + .withCallTimeout(Duration.ofSeconds(1L)) + .build(); + + var result = client.checkFeatureActivation(newSingleFeatureRequest(id)).join(); + assertThat(result).isTrue(); + + + mockServer.setGlobalFixedDelay(10_000); + Thread.sleep(5000); + + var localResult = client.checkFeatureActivation(newSingleFeatureRequest(id).withErrorStrategy(defaultValueStrategy(false))).join(); + assertThat(localResult).isTrue(); + } + + @Test + public void preload_should_aliment_cache() throws InterruptedException { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar1", true).withOverload(overload(true)); + var response = newResponse().withFeature(id, featureStub); + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String url = "/api/v2/features"; + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("features", equalTo(id)) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder() + .enabled(true) + .build() + ) + .withPreloadedFeatures(id) + .build(); + + client.isLoaded().join(); + + mockServer.resetAll(); + var result = client.checkFeatureActivation(newSingleFeatureRequest(id)).join(); + assertThat(result).isTrue(); + } + + @Test + public void preload_failure_should_no_aliment_cache() throws InterruptedException { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar1", true).withOverload(overload(true)); + var response = newResponse().withFeature(id, featureStub); + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String url = "/api/v2/features"; + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("features", equalTo(id)) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.serverError()) + ); + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ).withCacheConfiguration( + FeatureCacheConfiguration.newBuilder() + .enabled(true) + .build() + ) + .withPreloadedFeatures(id) + .build(); + + client.isLoaded().join(); + + mockServer.resetAll(); + var result = client.checkFeatureActivation(newSingleFeatureRequest(id)).join(); + assertThat(result).isNull(); + + mockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo(url)) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("features", equalTo(id)) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + result = client.checkFeatureActivation(newSingleFeatureRequest(id)).join(); + assertThat(result).isTrue(); + } + + @Test + public void request_with_payload_should_trigger_POST_queries() { + String id = "ae5dd05d-4e90-4ce7-bee7-3751750fdeaa"; + var featureStub = Mocks.feature("bar1", true).withOverload(overload(true)); + var response = newResponse().withFeature(id, featureStub); + String clientId = "87mpqvd86tskt43h"; + String clientSecret = "kmysr3rr431h9bx04hyk604jz0wwzr6v4vetgex3hb1u1xgnlrnirea13o31c9dg"; + String url = "/api/v2/features"; + + mockServer.stubFor(WireMock.post(WireMock.urlPathEqualTo(url)).withRequestBody(equalToJson("{\"foo\": \"bar\"}")) + .withQueryParam("conditions", equalTo("true")) + .withQueryParam("features", equalTo(id)) + .withHeader("Izanami-Client-Id", equalTo(clientId)) + .withHeader("Izanami-Client-Secret", equalTo(clientSecret)) + .willReturn(WireMock.ok().withHeader("Content-Type", "application/json") + .withBody(response.toJson()) + ) + ); + + var client = IzanamiClient + .newBuilder( + connectionInformation() + .withUrl("http://localhost:9999/api") + .withClientId(clientId) + .withClientSecret(clientSecret) + ) + .build(); + + var result = client.checkFeatureActivation(newSingleFeatureRequest(id).withPayload("{\"foo\": \"bar\"}")).join(); + assertThat(result).isTrue(); + } +} diff --git a/src/test/java/fr/maif/Mocks.java b/src/test/java/fr/maif/Mocks.java new file mode 100644 index 0000000..6385193 --- /dev/null +++ b/src/test/java/fr/maif/Mocks.java @@ -0,0 +1,195 @@ +package fr.maif; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +public class Mocks { + private static final ObjectMapper mapper = new ObjectMapper(); + + static { + mapper.registerModule(new Jdk8Module()); + mapper.registerModule(new JavaTimeModule()); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + public static MockedFeature feature(String name, boolean active) { + var f = new MockedFeature(); + f.name = name; + f.active = active; + return f; + } + + public static MockOverload overload(boolean enabled) { + var o = new MockOverload(); + o.enabled = enabled; + return o; + + } + + public static MockCondition condition(boolean enabled) { + return new MockCondition(); + } + + public static MockPeriod period() { + return new MockPeriod(); + } + + public static MockPercentageRule percentageRule(int percentage) { + var p = new MockPercentageRule(); + p.percentage = percentage; + return p; + } + + public static MockUserListRule userListRule(String... users) { + var us = Arrays.stream(users).collect(Collectors.toList()); + var u = new MockUserListRule(); + u.users = us; + return u; + } + + public static MockActivationDays days(String... days) { + var d = new MockActivationDays(); + d.days = Arrays.stream(days).collect(Collectors.toList()); + return d; + } + + public static MockedIzanamiResponse newResponse() { + return new MockedIzanamiResponse(); + } + + + public static class MockedIzanamiResponse { + Map features = new HashMap<>(); + + public MockedIzanamiResponse withFeature(String id, MockedFeature feature) { + this.features.put(id, feature); + return this; + } + + public String toJson() { + try { + return mapper.writeValueAsString(features); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + } + + public static class MockedFeature { + public String name; + public boolean active; + public String project = "default"; + public Map conditions = new HashMap<>(); + + public MockedFeature active(boolean active) { + this.active = active; + return this; + } + public MockedFeature withOverload(MockOverload condition) { + this.conditions.put("", condition); + return this; + } + + public MockedFeature withOverload(String context, MockOverload condition) { + this.conditions.put(context, condition); + return this; + } + + public String toJson() { + try { + return mapper.writeValueAsString(this); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + } + + public static class MockOverload { + public boolean enabled; + public List conditions = new ArrayList<>(); + public MockScript wasmConfig = null; + + public MockOverload withCondition(MockCondition condition) { + this.conditions.add(condition); + return this; + } + + public MockOverload withScript(String name) { + this.wasmConfig = new MockScript(name); + this.conditions = null; + return this; + } + + + } + + public static class MockScript { + public String name; + + public MockScript(String name) { + this.name = name; + } + } + + public static class MockCondition { + public MockPeriod period; + public MockRule rule; + + public MockCondition withPeriod(MockPeriod period) { + this.period = period; + return this; + } + + public MockCondition withRule(MockRule rule) { + this.rule = rule; + return this; + } + } + + public static class MockPeriod { + public LocalDateTime begin; + public LocalDateTime end; + public TimeZone timezone; + public MockActivationDays activationDays; + + public MockPeriod withBegin(LocalDateTime begin) { + this.begin = begin; + return this; + } + public MockPeriod withEnd(LocalDateTime end) { + this.end = end; + return this; + } + public MockPeriod withTimezone(TimeZone timezone) { + this.timezone = timezone; + return this; + } + public MockPeriod withActivationDays(MockActivationDays activationDays) { + this.activationDays = activationDays; + return this; + } + } + + public static class MockActivationDays { + public List days; + } + + public static abstract class MockRule { + } + + public static class MockPercentageRule extends MockRule { + public Integer percentage; + } + + public static class MockUserListRule extends MockRule { + public List users; + } +} diff --git a/src/test/resources/.gitkeep b/src/test/resources/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 0000000..ba3fa77 --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,18 @@ + + + + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} -%kvp- %msg%n + + + + + + + +