├── .github ├── stale.yml └── workflows │ ├── add-to-project.yml │ ├── build.yml │ └── release_jreleaser.yml ├── .gitignore ├── LICENSE ├── README.md ├── example ├── pom.xml └── src │ └── main │ ├── java │ └── io │ │ └── getunleash │ │ ├── FeatureDemoController.java │ │ ├── FeatureDemoService.java │ │ ├── FeatureDemoServiceNewImpl.java │ │ ├── FeatureDemoServiceOldImpl.java │ │ └── UnleashApplication.java │ └── resources │ └── application.yaml ├── pom.xml ├── springboot-unleash-autoconfigure ├── pom.xml └── src │ ├── main │ ├── java │ │ └── org │ │ │ └── unleash │ │ │ └── features │ │ │ ├── autoconfigure │ │ │ └── UnleashProperties.java │ │ │ └── config │ │ │ ├── UnleashAutoConfiguration.java │ │ │ └── UnleashCustomizer.java │ └── resources │ │ └── META-INF │ │ └── spring │ │ └── org.springframework.boot.autoconfigure.AutoConfiguration.imports │ └── test │ └── java │ └── org │ └── unleash │ └── features │ └── config │ └── UnleashAutoConfigurationTest.java ├── springboot-unleash-core ├── pom.xml └── src │ └── main │ └── java │ └── org │ └── unleash │ └── features │ ├── UnleashContextPreProcessor.java │ ├── annotation │ ├── Context.java │ ├── ContextPath.java │ ├── FeatureVariant.java │ ├── FeatureVariants.java │ └── Toggle.java │ └── aop │ ├── ContextAdvisor.java │ ├── ContextProxyAdvisor.java │ ├── FeatureAdvisor.java │ ├── FeatureProxyAdvisor.java │ ├── UnleashContextThreadLocal.java │ └── Utils.java └── springboot-unleash-starter └── pom.xml /.github/stale.yml: -------------------------------------------------------------------------------- 1 | _extends: .github 2 | -------------------------------------------------------------------------------- /.github/workflows/add-to-project.yml: -------------------------------------------------------------------------------- 1 | name: Add new item to project board 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | pull_request_target: 8 | types: 9 | - opened 10 | 11 | jobs: 12 | add-to-project: 13 | uses: unleash/.github/.github/workflows/add-item-to-project.yml@main 14 | secrets: inherit 15 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | version: 15 | - 17 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | - name: Setup java 20 | uses: actions/setup-java@v3 21 | with: 22 | distribution: 'temurin' 23 | java-version: ${{ matrix.version }} 24 | cache: 'maven' 25 | gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} 26 | gpg-passphrase: MAVEN_GPG_PASSPHRASE 27 | server-id: 'osrrh' 28 | server-username: MAVEN_USERNAME 29 | server-password: MAVEN_CENTRAL_TOKEN 30 | - name: Run tests 31 | run: | 32 | mvn clean verify -B 33 | env: 34 | MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} 35 | MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} 36 | MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} 37 | -------------------------------------------------------------------------------- /.github/workflows/release_jreleaser.yml: -------------------------------------------------------------------------------- 1 | name: Release (using JReleaser) 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "Release version" 8 | required: true 9 | nextVersion: 10 | description: "Next version after release (-SNAPSHOT will be added automatically)" 11 | required: true 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 45 17 | steps: 18 | - name: Generate token 19 | id: generate-token 20 | uses: actions/create-github-app-token@v1 21 | with: 22 | app-id: ${{ secrets.UNLEASH_BOT_APP_ID }} 23 | private-key: ${{ secrets.UNLEASH_BOT_PRIVATE_KEY }} 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 0 28 | token: ${{ steps.generate-token.outputs.token }} 29 | - name: Set up JDK 17 30 | uses: actions/setup-java@v4 31 | with: 32 | java-version: "17" 33 | distribution: "temurin" 34 | cache: maven 35 | - name: Set release version 36 | run: | 37 | mvn --no-transfer-progress --batch-mode versions:set -DnewVersion=${{ github.event.inputs.version }} 38 | - name: Commit & Push changes 39 | uses: actions-js/push@master 40 | with: 41 | github_token: ${{ steps.generate-token.outputs.token }} 42 | message: Releasing version ${{ github.event.inputs.version }} 43 | - name: Stage release 44 | run: | 45 | mvn --no-transfer-progress --batch-mode -Ppublication clean deploy -DaltDeploymentRepository=local::default::file://`pwd`/target/staging-deploy 46 | - name: Run JReleaser 47 | run: | 48 | mvn jreleaser:full-release 49 | env: 50 | JRELEASER_PROJECT_VERSION: ${{ github.event.inputs.version }} 51 | JRELEASER_GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} 52 | JRELEASER_GPG_PASSPHRASE: ${{ secrets.JRELEASER_GPG_PASSPHRASE }} 53 | JRELEASER_GPG_PUBLIC_KEY: ${{ secrets.JRELEASER_GPG_PUBLIC_KEY }} 54 | JRELEASER_GPG_SECRET_KEY: ${{ secrets.JRELEASER_GPG_SECRET_KEY }} 55 | JRELEASER_MAVENCENTRAL_USERNAME: ${{ secrets.JRELEASER_MAVENCENTRAL_USERNAME }} 56 | JRELEASER_MAVENCENTRAL_PASSWORD: ${{ secrets.JRELEASER_MAVENCENTRAL_PASSWORD }} 57 | - name: Set next version 58 | run: | 59 | mvn --no-transfer-progress --batch-mode versions:set -DnewVersion=${{ github.event.inputs.nextVersion }} 60 | - name: Commit & Push changes 61 | uses: actions-js/push@master 62 | with: 63 | github_token: ${{ steps.generate-token.outputs.token }} 64 | message: Setting SNAPSHOT version ${{ github.event.inputs.nextVersion }}-SNAPSHOT 65 | tags: true 66 | - name: JReleaser release output 67 | if: always() 68 | uses: actions/upload-artifact@v4 69 | with: 70 | name: jreleaser-release 71 | path: | 72 | out/jreleaser/trace.log 73 | out/jrelaser/output.properties 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /springboot-unleash-autoconfigure/target 3 | /springboot-unleash-core/target 4 | /springboot-unleash-starter/target 5 | /pom.xml.versionsBackup 6 | /springboot-unleash-autoconfigure/pom.xml.versionsBackup 7 | /springboot-unleash-core/pom.xml.versionsBackup 8 | /springboot-unleash-starter/pom.xml.versionsBackup 9 | /target -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spring boot starter for Unleash Client SDK for Java 2 | This provides a springboot starter for the official Unleash Client SDK for Java. 3 | This takes care of the required bootstrapping and creation of Unleash client. This also 4 | provides a annotation based approach to feature toggling similar to the functionality provided 5 | by FF4J client library. This also takes care of adding the unleash sdk as a transitive 6 | dependency. 7 | 8 | ## Getting started 9 | The following dependency needs to be added to the springboot project pom. 10 | 11 | ```xml 12 | 13 | io.getunleash 14 | springboot-unleash-starter 15 | Latest version here 16 | 17 | ``` 18 | 19 | ### Add the following to application.yaml 20 | ```yaml 21 | io: 22 | getunleash: 23 | app-name: 24 | instance-id: 25 | environment: 26 | api-url: 27 | api-token: 28 | ``` 29 | ex: 30 | ```yaml 31 | io: 32 | getunleash: 33 | app-name: springboot-test 34 | instance-id: instance x 35 | environment: development 36 | api-url: http://unleash.herokuapp.com/api/ 37 | api-token: '*:development.21a0a7f37e3ee92a0e601560808894ee242544996cdsdsdefgsfgdf' 38 | ``` 39 | - The configuration takes care of creating configuring `UnleashConfig` and creating an instance of `io.getunleash.Unleash`. 40 | - This takes care of binding all strategy instances (in-built and custom) to the `Unleash` instance. 41 | 42 | ### Including Spring Security Details in the Unleash Context 43 | Provide an `UnleashContextProvider` bean to add details that Unleash can leverage when evaluating toggle strategies: 44 | ```java 45 | @Bean 46 | @ConditionalOnMissingBean 47 | public UnleashContextProvider unleashContextProvider(final UnleashProperties unleashProperties) { 48 | return () -> { 49 | UnleashContext.Builder builder = UnleashContext.builder(); 50 | Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); 51 | if (principal instanceof User) { 52 | builder.userId(((User)principal).getUsername()); 53 | } 54 | 55 | return builder 56 | .appName(unleashProperties.getAppName()) 57 | .environment(unleashProperties.getEnvironment()) 58 | .build(); 59 | }; 60 | } 61 | ``` 62 | 63 | ### Usage 64 | - Create a feature toggle `demo-toggle` on unleash server and enabled it. 65 | - Create an interface FeatureDemoService and 2 implementation 66 | ```java 67 | public interface FeatureDemoService { 68 | String getDemoString(String name); 69 | } 70 | ``` 71 | ```java 72 | @Service("featureOldService") 73 | public class FeatureDemoOldServiceImpl implements FeatureDemoService { 74 | public String getDemoString(String name) { 75 | return "old implementation"; 76 | } 77 | } 78 | ``` 79 | ```java 80 | @Service("featureNewService") 81 | public class FeatureDemoNewServiceImpl implements FeatureDemoService { 82 | public String getDemoString(String name) { 83 | return "New implementation"; 84 | } 85 | } 86 | ``` 87 | - The requirement is that if the feature is enabled on the server, the new service implementation is used. 88 | - To get the above functionality add the `@Toggle` annotation to the interface, 89 | - If `Context` annotation is not used and UnleashContext is explicitly passed as a method parameter 90 | ```java 91 | import io.getunleash.UnleashContext; 92 | import org.unleash.features.annotation.Toggle; 93 | 94 | public interface FeatureDemoService { 95 | @Toggle(name="demo-toggle", alterBean="featureNewService") 96 | String getDemoString(String name, UnleashContext context); 97 | } 98 | ``` 99 | - If `@Context` annotation is used to set UnleashContext 100 | ```java 101 | import io.getunleash.UnleashContext; 102 | import org.unleash.features.annotation.Toggle; 103 | 104 | public interface FeatureDemoService { 105 | @Toggle(name="demo-toggle", alterBean="featureNewService") 106 | String getDemoString(String name); 107 | } 108 | ``` 109 | `FeatureDemoService` is injected where required. 110 | - If `Context` annotation is not used and UnleashContext is explicitly passed as a method parameter 111 | ```java 112 | import io.getunleash.UnleashContext; 113 | import org.unleash.features.annotation.Toggle; 114 | 115 | public interface FeatureDemoService { 116 | @Toggle(name="demo-toggle", alterBean="featureNewService") 117 | String getDemoString(String name, UnleashContext context); 118 | } 119 | ``` 120 | ```java 121 | @RestController 122 | @RequestMapping("/feature") 123 | public class FeatureDemoController { 124 | private final FeatureDemoService featureDemoService; 125 | 126 | public FeatureDemoController(@Qualifier("featureOldService") final FeatureDemoService featureDemoService) { 127 | this.featureDemoService = featureDemoService; 128 | } 129 | 130 | @GetMapping 131 | public String feature(@RequestParam final String name) { 132 | return featureDemoService.getDemoString(name, UnleashContext.builder().addProperty("name", name).build()); 133 | } 134 | } 135 | ``` 136 | - If `@Context` annotation is used to set UnleashContext 137 | ```java 138 | import io.getunleash.UnleashContext; 139 | import org.unleash.features.annotation.Toggle; 140 | 141 | public interface FeatureDemoService { 142 | @Toggle(name="demo-toggle", alterBean="featureNewService") 143 | String getDemoString(String name); 144 | } 145 | ``` 146 | ```java 147 | @RestController 148 | @RequestMapping("/feature") 149 | public class FeatureDemoController { 150 | private final FeatureDemoService featureDemoService; 151 | 152 | public FeatureDemoController(@Qualifier("featureOldService") final FeatureDemoService featureDemoService) { 153 | this.featureDemoService = featureDemoService; 154 | } 155 | 156 | @GetMapping 157 | public String feature(@RequestParam @Context(name = "name") final String name) { 158 | return featureDemoService.getDemoString(name); 159 | } 160 | } 161 | ``` 162 | 163 | - With the above, if the `demo-toggle` feature is enabled, the `featureNewService` is called even though `featureOldService` was injected. 164 | 165 | ### Example for variants 166 | - Toggle annotation has support for variants. It can be used as follows: 167 | ```java 168 | @Toggle(name = "background-color-feature", 169 | variants = @FeatureVariants( 170 | fallbackBean = "noBackgroundColorService", 171 | variants = { 172 | @FeatureVariant(name = "green-background-variant", variantBean = "greenBackgroundColorService"), 173 | @FeatureVariant(name = "red-background-variant", variantBean = "redBackgroundColorService") 174 | })) 175 | ``` 176 | - In the above example, there are 2 variants `green-background-variant` and `red-background-variant` defined in unleash. Here the implementation to be used for each variant is defined. `fallbackBean` is the implementation that will be used if a variant to bean mapping is not found. 177 | 178 | 179 | 180 | - git link to example app below: 181 | - https://github.com/praveenpg/unleash-starter-demo 182 | 183 | 184 | ## Development 185 | 186 | ### Releasing a new version 187 | 188 | If you have push rights to the repo, make sure there are no modifications that haven't been checked in. Then run 189 | ```bash 190 | mvn release:prepare 191 | ``` 192 | and answer the prompts for new release version. 193 | 194 | Once that's done, a new tag should've been created in the repo, and the [deploy release](./.github/workflows/deploy-release.yml) workflow will do the actual deploy. 195 | 196 | Then, run mvn release:clean to clean your repo for release artifacts which only clutters up the main branch. This will leave you 197 | -------------------------------------------------------------------------------- /example/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | org.springframework.boot 7 | spring-boot-starter-parent 8 | 3.0.1 9 | 10 | 11 | io.getunleash 12 | spring-boot-starter-demo 13 | 0.0.1-SNAPSHOT 14 | spring-boot-starter-demo 15 | Demo project for Spring Boot 16 | 17 | 17 18 | 19 | 20 | 21 | org.springframework.boot 22 | spring-boot-starter-web 23 | 24 | 25 | 26 | org.springframework.boot 27 | spring-boot-starter-test 28 | test 29 | 30 | 31 | io.getunleash 32 | springboot-unleash-starter 33 | 1.0.0-SNAPSHOT 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | org.springframework.boot 42 | spring-boot-maven-plugin 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /example/src/main/java/io/getunleash/FeatureDemoController.java: -------------------------------------------------------------------------------- 1 | package io.getunleash; 2 | 3 | import org.springframework.beans.factory.annotation.Qualifier; 4 | import org.springframework.web.bind.annotation.GetMapping; 5 | import org.springframework.web.bind.annotation.RequestMapping; 6 | import org.springframework.web.bind.annotation.RequestParam; 7 | import org.springframework.web.bind.annotation.RestController; 8 | import org.unleash.features.annotation.Context; 9 | 10 | @RestController 11 | @RequestMapping("/feature") 12 | public class FeatureDemoController { 13 | private final FeatureDemoService featureDemoService; 14 | public FeatureDemoController(@Qualifier("featureOldService") final FeatureDemoService featureDemoService) { 15 | this.featureDemoService = featureDemoService; 16 | } 17 | 18 | @GetMapping 19 | public String feature(@RequestParam @Context(name = "name") final String name) { 20 | return featureDemoService.getDemoString(name); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /example/src/main/java/io/getunleash/FeatureDemoService.java: -------------------------------------------------------------------------------- 1 | package io.getunleash; 2 | 3 | import org.unleash.features.annotation.Toggle; 4 | 5 | public interface FeatureDemoService { 6 | @Toggle(name = "feature-demo-toggle", alterBean = "featureNewService") 7 | String getDemoString(String name); 8 | } 9 | -------------------------------------------------------------------------------- /example/src/main/java/io/getunleash/FeatureDemoServiceNewImpl.java: -------------------------------------------------------------------------------- 1 | package io.getunleash; 2 | 3 | import org.springframework.stereotype.Service; 4 | 5 | @Service("featureNewService") 6 | public class FeatureDemoServiceNewImpl implements FeatureDemoService { 7 | @Override 8 | public String getDemoString(String name) { 9 | return "New implementation"; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /example/src/main/java/io/getunleash/FeatureDemoServiceOldImpl.java: -------------------------------------------------------------------------------- 1 | package io.getunleash; 2 | 3 | import org.springframework.stereotype.Service; 4 | 5 | @Service("featureOldService") 6 | public class FeatureDemoServiceOldImpl implements FeatureDemoService { 7 | @Override 8 | public String getDemoString(String name) { 9 | return "Old implementation"; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /example/src/main/java/io/getunleash/UnleashApplication.java: -------------------------------------------------------------------------------- 1 | package io.getunleash; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.context.ConfigurableApplicationContext; 6 | 7 | @SpringBootApplication 8 | public class UnleashApplication { 9 | public static void main(String[] args) { 10 | ConfigurableApplicationContext app = SpringApplication.run(UnleashApplication.class); 11 | System.out.println("Configured " +app.getBeanDefinitionCount() + " beans"); 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /example/src/main/resources/application.yaml: -------------------------------------------------------------------------------- 1 | io: 2 | getunleash: 3 | app-name: springboot-example 4 | instance-id: my-instance 5 | environment: development 6 | api-url: "http://localhost:4242/api" 7 | api-token: "*:development.32bb8c867665ddd377edfe37c50e2dcfa1d63d83c5c8bf1393156b83" 8 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | 5 | io.getunleash 6 | unleash-starter 7 | pom 8 | 1.2.3 9 | 10 | springboot-unleash-core 11 | springboot-unleash-autoconfigure 12 | springboot-unleash-starter 13 | 14 | 15 | Unleash features starter 16 | https://github.com/Unleash/unleash-spring-boot-starter 17 | 18 | 19 | 20 | Praveen Govindan 21 | praveen.govindan@gmail.com 22 | Praveen Govindan 23 | 24 | 25 | Christopher Kolstad 26 | chriswk@getunleash.io 27 | Unleash 28 | 29 | 30 | 31 | 32 | Spring boot starter for unleash feature toggle 33 | 34 | 35 | 36 | 17 37 | 17 38 | UTF-8 39 | 3.2.3 40 | 31.0.1-jre 41 | 42 | 43 | 44 | 45 | The Apache License, Version 2.0 46 | https://www.apache.org/licenses/LICENSE-2.0.txt 47 | 48 | 49 | 50 | 51 | scm:git:git@github.com:Unleash/unleash-spring-boot-starter.git 52 | scm:git:git@github.com:Unleash/unleash-spring-boot-starter.git 53 | https://github.com/Unleash/unleash-spring-boot-starter/tree/main 54 | HEAD 55 | 56 | 57 | 58 | 59 | 60 | org.springframework.boot 61 | spring-boot-dependencies 62 | ${spring.boot.version} 63 | pom 64 | import 65 | 66 | 67 | com.google.guava 68 | guava-bom 69 | ${guava.version} 70 | pom 71 | import 72 | 73 | 74 | 75 | 76 | 77 | 78 | publication 79 | 80 | local::file:./target/staging-deploy 81 | 82 | 83 | deploy 84 | 85 | 86 | org.apache.maven.plugins 87 | maven-javadoc-plugin 88 | 3.10.1 89 | 90 | 91 | attach-javadocs 92 | 93 | jar 94 | 95 | 96 | true 97 | 98 | 99 | 100 | 101 | 102 | org.apache.maven.plugins 103 | maven-source-plugin 104 | 3.3.1 105 | 106 | 107 | attach-sources 108 | 109 | jar 110 | 111 | 112 | true 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | org.jreleaser 127 | jreleaser-maven-plugin 128 | 1.17.0 129 | false 130 | 131 | 132 | 133 | ALWAYS 134 | true 135 | 136 | 137 | 138 | 139 | 140 | ALWAYS 141 | https://central.sonatype.com/api/v1/publisher 142 | target/staging-deploy 143 | 60 144 | 40 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | org.apache.maven.plugins 154 | maven-release-plugin 155 | 3.1.1 156 | 157 | release 158 | v@{project.version} 159 | 160 | 161 | 162 | org.apache.maven.plugins 163 | maven-compiler-plugin 164 | 3.10.0 165 | 166 | ${maven.compiler.source} 167 | ${maven.compiler.target} 168 | 169 | 170 | 171 | org.apache.maven.plugins 172 | maven-plugin-plugin 173 | 3.6.4 174 | 175 | 176 | default-descriptor 177 | process-classes 178 | 179 | 180 | 181 | help-goal 182 | 183 | helpmojo 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | -------------------------------------------------------------------------------- /springboot-unleash-autoconfigure/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | unleash-starter 5 | io.getunleash 6 | 1.2.3 7 | 8 | 4.0.0 9 | Unleash Spring Boot Starter Autoconfigure 10 | springboot-unleash-autoconfigure 11 | Autoconfigure module for Unleash Spring Boot starter 12 | https://github.com/Unleash/unleash-spring-boot-starter 13 | 14 | 15 | 17 16 | 17 17 | UTF-8 18 | 19 | 20 | 21 | 22 | org.springframework.boot 23 | spring-boot 24 | 25 | 26 | 27 | org.springframework.boot 28 | spring-boot-autoconfigure 29 | 30 | 31 | 32 | org.springframework.boot 33 | spring-boot-configuration-processor 34 | true 35 | 36 | 37 | 38 | io.getunleash 39 | springboot-unleash-core 40 | ${project.version} 41 | 42 | 43 | 44 | org.springframework.boot 45 | spring-boot-starter-test 46 | test 47 | 48 | 49 | 50 | 51 | 52 | 53 | org.apache.maven.plugins 54 | maven-compiler-plugin 55 | 3.10.1 56 | 57 | true 58 | 17 59 | 60 | 61 | 62 | org.apache.maven.plugins 63 | maven-source-plugin 64 | 3.2.1 65 | 66 | 67 | attach-sources 68 | 69 | jar 70 | 71 | 72 | 73 | 74 | 75 | org.apache.maven.plugins 76 | maven-javadoc-plugin 77 | 3.4.1 78 | 79 | private 80 | true 81 | 17 82 | 83 | 84 | 85 | attach-javadocs 86 | 87 | jar 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /springboot-unleash-autoconfigure/src/main/java/org/unleash/features/autoconfigure/UnleashProperties.java: -------------------------------------------------------------------------------- 1 | package org.unleash.features.autoconfigure; 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties; 4 | 5 | import java.time.Duration; 6 | import java.util.List; 7 | 8 | import static org.unleash.features.autoconfigure.UnleashProperties.PREFIX; 9 | 10 | @ConfigurationProperties(prefix = PREFIX) 11 | public class UnleashProperties { 12 | private String appName; 13 | private String instanceId; 14 | private String environment; 15 | private String apiUrl; 16 | private String apiToken; 17 | private String projectName; 18 | private boolean disableMetrics = false; 19 | private Duration fetchTogglesInterval = Duration.ofSeconds(10); 20 | private Duration fetchTogglesConnectTimeout = Duration.ofSeconds(10); 21 | private Duration fetchTogglesReadTimeout = Duration.ofSeconds(10); 22 | private Duration sendMetricsInterval = Duration.ofSeconds(10); 23 | private Duration sendMetricsConnectTimeout = Duration.ofSeconds(10); 24 | private Duration sendMetricsReadTimeout = Duration.ofSeconds(10); 25 | private HttpFetcher httpFetcher = HttpFetcher.HTTP_URL_CONNECTION_FETCHER; 26 | private boolean synchronousFetchOnInitialisation = false; 27 | private boolean proxyAuthenticationByJvmProperties = false; 28 | private List customHttpHeadersProvider; 29 | 30 | public static final String PREFIX = "io.getunleash"; 31 | 32 | public String getAppName() { 33 | return appName; 34 | } 35 | 36 | public void setAppName(String appName) { 37 | this.appName = appName; 38 | } 39 | 40 | public String getInstanceId() { 41 | return instanceId; 42 | } 43 | 44 | public void setInstanceId(String instanceId) { 45 | this.instanceId = instanceId; 46 | } 47 | 48 | public String getEnvironment() { 49 | return environment; 50 | } 51 | 52 | public void setEnvironment(String environment) { 53 | this.environment = environment; 54 | } 55 | 56 | public String getApiUrl() { 57 | return apiUrl; 58 | } 59 | 60 | public void setApiUrl(String apiUrl) { 61 | this.apiUrl = apiUrl; 62 | } 63 | 64 | public String getApiToken() { 65 | return apiToken; 66 | } 67 | 68 | public void setApiToken(String apiToken) { 69 | this.apiToken = apiToken; 70 | } 71 | 72 | public HttpFetcher getHttpFetcher() { 73 | return httpFetcher; 74 | } 75 | 76 | public void setHttpFetcher(HttpFetcher httpFetcher) { 77 | this.httpFetcher = httpFetcher; 78 | } 79 | 80 | public String getProjectName() { 81 | return projectName; 82 | } 83 | 84 | public void setProjectName(String projectName) { 85 | this.projectName = projectName; 86 | } 87 | 88 | public Duration getFetchTogglesInterval() { 89 | return fetchTogglesInterval; 90 | } 91 | 92 | public void setFetchTogglesInterval(Duration fetchTogglesInterval) { 93 | this.fetchTogglesInterval = fetchTogglesInterval; 94 | } 95 | 96 | public boolean isDisableMetrics() { 97 | return disableMetrics; 98 | } 99 | 100 | public void setDisableMetrics(boolean disableMetrics) { 101 | this.disableMetrics = disableMetrics; 102 | } 103 | 104 | public Duration getSendMetricsInterval() { 105 | return sendMetricsInterval; 106 | } 107 | 108 | public void setSendMetricsInterval(Duration sendMetricsInterval) { 109 | this.sendMetricsInterval = sendMetricsInterval; 110 | } 111 | 112 | public Duration getSendMetricsConnectTimeout() { 113 | return sendMetricsConnectTimeout; 114 | } 115 | 116 | public void setSendMetricsConnectTimeout(Duration sendMetricsConnectTimeout) { 117 | this.sendMetricsConnectTimeout = sendMetricsConnectTimeout; 118 | } 119 | 120 | public Duration getSendMetricsReadTimeout() { 121 | return sendMetricsReadTimeout; 122 | } 123 | 124 | public void setSendMetricsReadTimeout(Duration sendMetricsReadTimeout) { 125 | this.sendMetricsReadTimeout = sendMetricsReadTimeout; 126 | } 127 | 128 | public Duration getFetchTogglesConnectTimeout() { 129 | return fetchTogglesConnectTimeout; 130 | } 131 | 132 | public void setFetchTogglesConnectTimeout(Duration fetchTogglesConnectTimeout) { 133 | this.fetchTogglesConnectTimeout = fetchTogglesConnectTimeout; 134 | } 135 | 136 | public Duration getFetchTogglesReadTimeout() { 137 | return fetchTogglesReadTimeout; 138 | } 139 | 140 | public void setFetchTogglesReadTimeout(Duration fetchTogglesReadTimeout) { 141 | this.fetchTogglesReadTimeout = fetchTogglesReadTimeout; 142 | } 143 | 144 | public boolean isSynchronousFetchOnInitialisation() { 145 | return synchronousFetchOnInitialisation; 146 | } 147 | 148 | public void setSynchronousFetchOnInitialisation(boolean synchronousFetchOnInitialisation) { 149 | this.synchronousFetchOnInitialisation = synchronousFetchOnInitialisation; 150 | } 151 | 152 | public boolean isProxyAuthenticationByJvmProperties() { 153 | return proxyAuthenticationByJvmProperties; 154 | } 155 | 156 | public void setProxyAuthenticationByJvmProperties(boolean proxyAuthenticationByJvmProperties) { 157 | this.proxyAuthenticationByJvmProperties = proxyAuthenticationByJvmProperties; 158 | } 159 | 160 | public List getCustomHttpHeadersProvider() { 161 | return customHttpHeadersProvider; 162 | } 163 | 164 | public void setCustomHttpHeadersProvider(final List customHttpHeadersProvider) { 165 | this.customHttpHeadersProvider = customHttpHeadersProvider; 166 | } 167 | 168 | public enum HttpFetcher { 169 | HTTP_URL_CONNECTION_FETCHER, 170 | OK_HTTP 171 | } 172 | 173 | public static final class CustomHeader { 174 | private String name; 175 | private String value; 176 | 177 | public String getName() { 178 | return name; 179 | } 180 | 181 | public void setName(String name) { 182 | this.name = name; 183 | } 184 | 185 | public String getValue() { 186 | return value; 187 | } 188 | 189 | public void setValue(String value) { 190 | this.value = value; 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /springboot-unleash-autoconfigure/src/main/java/org/unleash/features/config/UnleashAutoConfiguration.java: -------------------------------------------------------------------------------- 1 | package org.unleash.features.config; 2 | 3 | import io.getunleash.DefaultUnleash; 4 | import io.getunleash.Unleash; 5 | import io.getunleash.UnleashContext; 6 | import io.getunleash.UnleashContextProvider; 7 | import io.getunleash.event.NoOpSubscriber; 8 | import io.getunleash.event.UnleashSubscriber; 9 | import io.getunleash.repository.OkHttpFeatureFetcher; 10 | import io.getunleash.strategy.Strategy; 11 | import io.getunleash.util.UnleashConfig; 12 | import org.jetbrains.annotations.NotNull; 13 | import org.springframework.beans.factory.ObjectProvider; 14 | import org.springframework.beans.factory.annotation.Autowired; 15 | import org.springframework.boot.autoconfigure.AutoConfiguration; 16 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; 17 | import org.springframework.boot.context.properties.EnableConfigurationProperties; 18 | import org.springframework.context.annotation.Bean; 19 | import org.springframework.context.annotation.ComponentScan; 20 | import org.springframework.util.CollectionUtils; 21 | import org.springframework.util.StringUtils; 22 | import org.unleash.features.aop.UnleashContextThreadLocal; 23 | import org.unleash.features.aop.Utils; 24 | import org.unleash.features.autoconfigure.UnleashProperties; 25 | 26 | import java.util.Collections; 27 | import java.util.HashMap; 28 | import java.util.Map; 29 | import java.util.UUID; 30 | import java.util.stream.Collectors; 31 | 32 | @SuppressWarnings("SpringJavaAutowiredFieldsWarningInspection") 33 | @EnableConfigurationProperties(UnleashProperties.class) 34 | @AutoConfiguration 35 | @ComponentScan("org.unleash.features.aop") 36 | public class UnleashAutoConfiguration { 37 | @Autowired(required = false) 38 | private Map strategyMap; 39 | 40 | @Bean 41 | @ConditionalOnMissingBean 42 | public UnleashContextProvider unleashContextProvider(final UnleashProperties unleashProperties) { 43 | return () -> UnleashContext.builder() 44 | .appName(unleashProperties.getAppName()) 45 | .environment(unleashProperties.getEnvironment()) 46 | .build(); 47 | } 48 | 49 | @Bean 50 | @ConditionalOnMissingBean 51 | public UnleashSubscriber unleashSubscriber() { 52 | return new NoOpSubscriber(); 53 | } 54 | 55 | @Bean 56 | @ConditionalOnMissingBean 57 | public UnleashConfig unleashConfig(final UnleashProperties unleashProperties, 58 | UnleashContextProvider unleashContextProvider, 59 | UnleashSubscriber unleashSubscriber, 60 | ObjectProvider customizers 61 | ) { 62 | final var provider = getUnleashContextProviderWithThreadLocalSupport(unleashContextProvider); 63 | final var builder = UnleashConfig 64 | .builder() 65 | .unleashContextProvider(provider) 66 | .appName(unleashProperties.getAppName()) 67 | .environment(unleashProperties.getEnvironment()) 68 | .unleashAPI(unleashProperties.getApiUrl()) 69 | .fetchTogglesConnectTimeout(unleashProperties.getFetchTogglesConnectTimeout()) 70 | .fetchTogglesReadTimeout(unleashProperties.getFetchTogglesReadTimeout()) 71 | .fetchTogglesInterval(unleashProperties.getFetchTogglesInterval().getSeconds()) 72 | .sendMetricsInterval(unleashProperties.getSendMetricsInterval().getSeconds()) 73 | .sendMetricsConnectTimeout(unleashProperties.getSendMetricsConnectTimeout()) 74 | .sendMetricsReadTimeout(unleashProperties.getSendMetricsReadTimeout()) 75 | .customHttpHeader("Authorization", unleashProperties.getApiToken()) 76 | .projectName(unleashProperties.getProjectName()) 77 | .subscriber(unleashSubscriber) 78 | .synchronousFetchOnInitialisation(unleashProperties.isSynchronousFetchOnInitialisation()) 79 | .instanceId(StringUtils.hasText(unleashProperties.getInstanceId()) ? unleashProperties.getInstanceId() : 80 | UUID.randomUUID().toString()); 81 | 82 | setDisableMetrics(builder, unleashProperties); 83 | setHttpFetcherInBuilder(builder, unleashProperties); 84 | setProxyAuthenticationByJvmProps(builder, unleashProperties); 85 | setCustomHeaderProvider(builder, unleashProperties); 86 | 87 | customizers.orderedStream().forEach((customizer) -> customizer.customize(builder)); 88 | 89 | return builder.build(); 90 | } 91 | 92 | @Bean 93 | @ConditionalOnMissingBean 94 | public Unleash unleash(UnleashConfig config) { 95 | return !CollectionUtils.isEmpty(strategyMap) ? new DefaultUnleash(config, strategyMap.values().toArray(new Strategy[0])) : 96 | new DefaultUnleash(config); 97 | } 98 | 99 | /** 100 | * Method always wraps the created UnleashContextProvider with threadLocal support. 101 | */ 102 | @NotNull 103 | @SuppressWarnings("ConstantConditions") 104 | private UnleashContextProvider getUnleashContextProviderWithThreadLocalSupport(UnleashContextProvider unleashContextProvider) { 105 | return () -> { 106 | final Map threadLocalContextMap = UnleashContextThreadLocal.getContextMap(); 107 | 108 | if (CollectionUtils.isEmpty(threadLocalContextMap)) { 109 | return unleashContextProvider.getContext(); 110 | } else { 111 | final var context = unleashContextProvider.getContext(); 112 | final var builder = UnleashContext.builder(); 113 | final var currentContextMap = new HashMap<>(context.getProperties() != null ? context.getProperties() : Collections.emptyMap()); 114 | 115 | currentContextMap.putAll(threadLocalContextMap); 116 | 117 | context.getAppName().ifPresent(builder::appName); 118 | context.getEnvironment().ifPresent(builder::environment); 119 | context.getCurrentTime().ifPresent(builder::currentTime); 120 | context.getRemoteAddress().ifPresent(builder::remoteAddress); 121 | context.getSessionId().ifPresent(builder::sessionId); 122 | 123 | currentContextMap.forEach((key, value) -> Utils.setContextBuilderProperty(builder, key, value)); 124 | 125 | return builder.build(); 126 | } 127 | }; 128 | } 129 | 130 | private void setHttpFetcherInBuilder(UnleashConfig.Builder builder, UnleashProperties unleashProperties) { 131 | if (unleashProperties.getHttpFetcher() != UnleashProperties.HttpFetcher.HTTP_URL_CONNECTION_FETCHER) { 132 | builder.unleashFeatureFetcherFactory(OkHttpFeatureFetcher::new); 133 | } 134 | } 135 | 136 | private void setProxyAuthenticationByJvmProps(UnleashConfig.Builder builder, UnleashProperties properties) { 137 | if (properties.isProxyAuthenticationByJvmProperties()) { 138 | builder.enableProxyAuthenticationByJvmProperties(); 139 | } 140 | } 141 | 142 | 143 | private void setCustomHeaderProvider(UnleashConfig.Builder builder, UnleashProperties properties) { 144 | if (!CollectionUtils.isEmpty(properties.getCustomHttpHeadersProvider())) { 145 | builder.customHttpHeadersProvider(() -> properties.getCustomHttpHeadersProvider().stream() 146 | .collect(Collectors.toMap(UnleashProperties.CustomHeader::getName, UnleashProperties.CustomHeader::getValue))); 147 | } 148 | } 149 | 150 | 151 | private void setDisableMetrics(UnleashConfig.Builder builder, UnleashProperties properties) { 152 | if (properties.isDisableMetrics()) { 153 | builder.disableMetrics(); 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /springboot-unleash-autoconfigure/src/main/java/org/unleash/features/config/UnleashCustomizer.java: -------------------------------------------------------------------------------- 1 | package org.unleash.features.config; 2 | 3 | import io.getunleash.util.UnleashConfig; 4 | 5 | /** 6 | * Callback interface that can be used to customize Unleash with a {@link UnleashConfig.Builder}. 7 | * 8 | * @author Ivan Rodriguez 9 | * @since 1.1.2 10 | */ 11 | @FunctionalInterface 12 | public interface UnleashCustomizer { 13 | 14 | /** 15 | * Callback to customize a {@link UnleashConfig.Builder} instance. 16 | * 17 | * @param builder Unleash builder to customize 18 | */ 19 | void customize(UnleashConfig.Builder builder); 20 | 21 | } 22 | -------------------------------------------------------------------------------- /springboot-unleash-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports: -------------------------------------------------------------------------------- 1 | org.unleash.features.config.UnleashAutoConfiguration 2 | -------------------------------------------------------------------------------- /springboot-unleash-autoconfigure/src/test/java/org/unleash/features/config/UnleashAutoConfigurationTest.java: -------------------------------------------------------------------------------- 1 | package org.unleash.features.config; 2 | 3 | import io.getunleash.DefaultUnleash; 4 | import io.getunleash.Unleash; 5 | import io.getunleash.UnleashContextProvider; 6 | import io.getunleash.event.EventDispatcher; 7 | import io.getunleash.event.NoOpSubscriber; 8 | import io.getunleash.event.UnleashSubscriber; 9 | import io.getunleash.metric.UnleashMetricService; 10 | import io.getunleash.repository.FeatureRepository; 11 | import io.getunleash.strategy.Strategy; 12 | import io.getunleash.strategy.UnknownStrategy; 13 | import io.getunleash.util.UnleashConfig; 14 | import io.getunleash.util.UnleashScheduledExecutorImpl; 15 | import org.assertj.core.api.InstanceOfAssertFactories; 16 | import org.junit.jupiter.api.Test; 17 | import org.springframework.boot.autoconfigure.AutoConfigurations; 18 | import org.springframework.boot.test.context.runner.ApplicationContextRunner; 19 | import org.springframework.context.annotation.Bean; 20 | import org.springframework.context.annotation.Configuration; 21 | import org.unleash.features.autoconfigure.UnleashProperties; 22 | 23 | import java.net.URI; 24 | import java.util.HashMap; 25 | import java.util.Map; 26 | 27 | import static org.assertj.core.api.Assertions.assertThat; 28 | import static org.mockito.Mockito.mock; 29 | import static org.unleash.features.autoconfigure.UnleashProperties.PREFIX; 30 | 31 | /** 32 | * Tests for {@link UnleashAutoConfiguration}. 33 | * 34 | * @author Ivan Rodriguez 35 | */ 36 | class UnleashAutoConfigurationTest { 37 | private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() 38 | .withConfiguration(AutoConfigurations.of(UnleashAutoConfiguration.class)); 39 | 40 | final String[] requiredProperties = {PREFIX + ".appName=Foo", PREFIX + ".apiUrl=https://example.com:4242/api"}; 41 | 42 | @Test 43 | void shouldSupplyDefaultBeans() { 44 | this.contextRunner 45 | .withPropertyValues(requiredProperties) 46 | .run((context) -> { 47 | assertThat(context).hasSingleBean(UnleashContextProvider.class); 48 | assertThat(context).hasSingleBean(UnleashSubscriber.class); 49 | assertThat(context).hasSingleBean(Unleash.class); 50 | assertThat(context).hasSingleBean(UnleashConfig.class); 51 | }); 52 | } 53 | 54 | @Test 55 | void shouldConfigureDefaultUnleash() { 56 | this.contextRunner 57 | .withUserConfiguration(DefaultConfiguration.class) 58 | .withPropertyValues(requiredProperties) 59 | .run((context) -> { 60 | Unleash unleash = context.getBean(Unleash.class); 61 | assertThat(unleash) 62 | .extracting("config") 63 | .asInstanceOf(InstanceOfAssertFactories.type(UnleashConfig.class)) 64 | .hasFieldOrPropertyWithValue("appName", "Foo") 65 | .hasFieldOrPropertyWithValue("unleashScheduledExecutor", UnleashScheduledExecutorImpl.getInstance()) 66 | .hasFieldOrPropertyWithValue("unleashAPI", URI.create("https://example.com:4242/api")); 67 | 68 | assertThat(unleash) 69 | .extracting("config.unleashSubscriber") 70 | .isInstanceOf(NoOpSubscriber.class); 71 | 72 | assertThat(unleash) 73 | .extracting("config.fallbackStrategy") 74 | .isInstanceOf(UnknownStrategy.class); 75 | }); 76 | } 77 | 78 | @Test 79 | void shouldConfigureUnleashWithCustomizer() { 80 | this.contextRunner 81 | .withUserConfiguration(CustomizerConfiguration.class) 82 | .withPropertyValues(requiredProperties) 83 | .run((context) -> { 84 | Unleash unleash = context.getBean(Unleash.class); 85 | assertThat(unleash) 86 | .extracting("config.fallbackStrategy") 87 | .hasFieldOrPropertyWithValue("name", "a_fallback_for Foo"); 88 | }); 89 | } 90 | 91 | @Configuration(proxyBeanMethods = false) 92 | static class DefaultConfiguration { 93 | 94 | @Bean 95 | public Unleash unleash(@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") UnleashConfig config) { 96 | return new DefaultUnleash(config, 97 | new FeatureRepository(config), 98 | new HashMap<>(), 99 | mock(UnleashContextProvider.class), 100 | mock(EventDispatcher.class), 101 | mock(UnleashMetricService.class), 102 | false); 103 | } 104 | 105 | } 106 | 107 | @Configuration(proxyBeanMethods = false) 108 | static class CustomizerConfiguration extends DefaultConfiguration { 109 | 110 | @Bean 111 | public UnleashCustomizer unleashFallbackCustomizer(@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") UnleashProperties properties) { 112 | return builder -> builder.fallbackStrategy(new Strategy() { 113 | 114 | @Override 115 | public String getName() { 116 | return "a_fallback_for " + properties.getAppName(); 117 | } 118 | 119 | @Override 120 | public boolean isEnabled(Map map) { 121 | return false; 122 | } 123 | }); 124 | } 125 | 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /springboot-unleash-core/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | unleash-starter 5 | io.getunleash 6 | 1.2.3 7 | 8 | 4.0.0 9 | Unleash Spring Boot Starter Core 10 | springboot-unleash-core 11 | Core module for Unleash Spring Boot starter 12 | https://github.com/Unleash/unleash-spring-boot-starter 13 | 14 | 15 | 17 16 | 17 17 | UTF-8 18 | 1.9.9.1 19 | 9.3.2 20 | 21 | 22 | 23 | 24 | org.springframework.boot 25 | spring-boot 26 | 27 | 28 | org.springframework 29 | spring-aop 30 | 31 | 32 | org.springframework 33 | spring-aspects 34 | 35 | 36 | org.aspectj 37 | aspectjweaver 38 | 39 | 40 | 41 | 42 | org.springframework 43 | spring-context 44 | 45 | 46 | io.getunleash 47 | unleash-client-java 48 | ${unleash.client.version} 49 | 50 | 51 | com.squareup.okhttp3 52 | okhttp 53 | 4.12.0 54 | 55 | 56 | org.aspectj 57 | aspectjweaver 58 | ${aspectj.version} 59 | 60 | 61 | org.aspectj 62 | aspectjrt 63 | ${aspectj.version} 64 | 65 | 66 | 67 | 68 | 69 | 70 | org.apache.maven.plugins 71 | maven-compiler-plugin 72 | 3.10.0 73 | 74 | true 75 | 17 76 | 77 | 78 | 79 | org.codehaus.mojo 80 | aspectj-maven-plugin 81 | 1.14.0 82 | 83 | 17 84 | 17 85 | true 86 | ${project.build.sourceEncoding} 87 | ignore 88 | true 89 | 90 | 91 | ${project.build.directory}/classes 92 | 93 | 94 | 95 | org.springframework 96 | spring-aspects 97 | 98 | 99 | 100 | 101 | 102 | process-classes 103 | 104 | compile 105 | 106 | 107 | 108 | 109 | 110 | org.aspectj 111 | aspectjrt 112 | ${aspectj.version} 113 | 114 | 115 | org.aspectj 116 | aspectjtools 117 | ${aspectj.version} 118 | 119 | 120 | 121 | 122 | org.apache.maven.plugins 123 | maven-source-plugin 124 | 3.2.1 125 | 126 | 127 | attach-sources 128 | 129 | jar 130 | 131 | 132 | 133 | 134 | 135 | org.apache.maven.plugins 136 | maven-javadoc-plugin 137 | 3.4.1 138 | 139 | private 140 | true 141 | 11 142 | 143 | 144 | 145 | attach-javadocs 146 | 147 | jar 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /springboot-unleash-core/src/main/java/org/unleash/features/UnleashContextPreProcessor.java: -------------------------------------------------------------------------------- 1 | package org.unleash.features; 2 | 3 | import java.util.function.Supplier; 4 | 5 | public interface UnleashContextPreProcessor { 6 | default Supplier preProcess(final Supplier supplier) { 7 | return () -> { 8 | try { 9 | process(); 10 | return supplier.get(); 11 | } finally { 12 | cleanup(); 13 | } 14 | }; 15 | } 16 | 17 | void process(); 18 | void cleanup(); 19 | } 20 | -------------------------------------------------------------------------------- /springboot-unleash-core/src/main/java/org/unleash/features/annotation/Context.java: -------------------------------------------------------------------------------- 1 | package org.unleash.features.annotation; 2 | 3 | import java.lang.annotation.*; 4 | 5 | @Target({ElementType.PARAMETER}) 6 | @Retention(RetentionPolicy.RUNTIME) 7 | @Documented 8 | public @interface Context { 9 | String name(); 10 | } 11 | -------------------------------------------------------------------------------- /springboot-unleash-core/src/main/java/org/unleash/features/annotation/ContextPath.java: -------------------------------------------------------------------------------- 1 | package org.unleash.features.annotation; 2 | 3 | public enum ContextPath { 4 | METHOD, THREADLOCAL 5 | } 6 | -------------------------------------------------------------------------------- /springboot-unleash-core/src/main/java/org/unleash/features/annotation/FeatureVariant.java: -------------------------------------------------------------------------------- 1 | package org.unleash.features.annotation; 2 | 3 | import java.lang.annotation.*; 4 | 5 | @Target(ElementType.ANNOTATION_TYPE) 6 | @Retention(RetentionPolicy.RUNTIME) 7 | @Documented 8 | public @interface FeatureVariant { 9 | String name(); 10 | String variantBean(); 11 | } 12 | -------------------------------------------------------------------------------- /springboot-unleash-core/src/main/java/org/unleash/features/annotation/FeatureVariants.java: -------------------------------------------------------------------------------- 1 | package org.unleash.features.annotation; 2 | 3 | import java.lang.annotation.*; 4 | 5 | @Target(ElementType.ANNOTATION_TYPE) 6 | @Retention(RetentionPolicy.RUNTIME) 7 | @Documented 8 | public @interface FeatureVariants { 9 | String fallbackBean() default ""; 10 | FeatureVariant[] variants() default {}; 11 | } 12 | -------------------------------------------------------------------------------- /springboot-unleash-core/src/main/java/org/unleash/features/annotation/Toggle.java: -------------------------------------------------------------------------------- 1 | package org.unleash.features.annotation; 2 | 3 | import java.lang.annotation.*; 4 | 5 | @Target(ElementType.METHOD) 6 | @Retention(RetentionPolicy.RUNTIME) 7 | @Documented 8 | public @interface Toggle { 9 | String name() default ""; 10 | String alterBean() default ""; 11 | FeatureVariants variants() default @FeatureVariants(); 12 | } 13 | -------------------------------------------------------------------------------- /springboot-unleash-core/src/main/java/org/unleash/features/aop/ContextAdvisor.java: -------------------------------------------------------------------------------- 1 | package org.unleash.features.aop; 2 | 3 | import org.aopalliance.intercept.MethodInterceptor; 4 | import org.aopalliance.intercept.MethodInvocation; 5 | import org.springframework.stereotype.Component; 6 | import org.unleash.features.annotation.Context; 7 | 8 | import java.lang.annotation.Annotation; 9 | import java.util.Arrays; 10 | import java.util.stream.IntStream; 11 | 12 | 13 | @Component("context.advisor") 14 | public class ContextAdvisor implements MethodInterceptor { 15 | @Override 16 | public Object invoke(final MethodInvocation invocation) throws Throwable { 17 | try { 18 | final var params = invocation.getArguments(); 19 | final var annotations = invocation.getMethod().getParameterAnnotations(); 20 | final Class[] parameterTypes = invocation.getMethod().getParameterTypes(); 21 | 22 | IntStream.range(0, params.length) 23 | .forEach(index -> Arrays.stream(annotations[index]) 24 | .forEach(annotation -> setUnleashContext(parameterTypes, params, index, annotation))); 25 | 26 | return invocation.proceed(); 27 | } finally { 28 | UnleashContextThreadLocal.unset(); 29 | } 30 | } 31 | 32 | private void setUnleashContext(final Class[] parameterTypes, 33 | final Object[] params, 34 | final int index, 35 | final Annotation annotation) { 36 | 37 | if (annotation.annotationType() == Context.class) { 38 | final Object arg = params[index]; 39 | final Class parameterType = parameterTypes[index]; 40 | final Context contextAnnotation = (Context) annotation; 41 | 42 | if (arg != null) { 43 | if (parameterType != String.class) { 44 | throw new IllegalArgumentException("Only string params can be annotated with Context annotation"); 45 | } 46 | 47 | UnleashContextThreadLocal.addContextProperty(contextAnnotation.name(), (String) arg); 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /springboot-unleash-core/src/main/java/org/unleash/features/aop/ContextProxyAdvisor.java: -------------------------------------------------------------------------------- 1 | package org.unleash.features.aop; 2 | 3 | import org.springframework.aop.TargetSource; 4 | import org.springframework.aop.framework.autoproxy.AbstractAutoProxyCreator; 5 | import org.springframework.beans.BeansException; 6 | import org.springframework.stereotype.Component; 7 | import org.unleash.features.annotation.Context; 8 | 9 | import java.lang.annotation.Annotation; 10 | import java.lang.reflect.Method; 11 | import java.util.Arrays; 12 | 13 | 14 | @Component("contextProxyAdvisor") 15 | public class ContextProxyAdvisor extends AbstractAutoProxyCreator { 16 | public ContextProxyAdvisor() { 17 | setInterceptorNames(getBeanNameOfFeatureAdvisor()); 18 | } 19 | 20 | private String getBeanNameOfFeatureAdvisor() { 21 | return ContextAdvisor.class.getAnnotation(Component.class).value(); 22 | } 23 | 24 | @Override 25 | protected Object[] getAdvicesAndAdvisorsForBean(final Class beanClass, final String beanName, final TargetSource customTargetSource) throws BeansException { 26 | 27 | if(!beanClass.isInterface()) { 28 | final Method[] methods = beanClass.getMethods(); 29 | final boolean isAnnotatedWithContext = Arrays.stream(methods).anyMatch(this::hasAnnotation); 30 | 31 | if(isAnnotatedWithContext) { 32 | return PROXY_WITHOUT_ADDITIONAL_INTERCEPTORS; 33 | } 34 | } 35 | return DO_NOT_PROXY; 36 | } 37 | 38 | private boolean hasAnnotation(final Method method) { 39 | final var params = method.getParameters(); 40 | final var annotationArr = method.getParameterAnnotations(); 41 | boolean present = false; 42 | 43 | for(int i = 0; i < params.length; i++) { 44 | final Annotation[] annotations = annotationArr[i]; 45 | 46 | for (Annotation annotation : annotations) { 47 | if(annotation.annotationType() == Context.class) { 48 | present = true; 49 | 50 | if(params[i].getType() != String.class) { 51 | throw new IllegalArgumentException("Only String type can be annotated with Context"); 52 | } 53 | } 54 | } 55 | } 56 | 57 | return present; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /springboot-unleash-core/src/main/java/org/unleash/features/aop/FeatureAdvisor.java: -------------------------------------------------------------------------------- 1 | package org.unleash.features.aop; 2 | 3 | import io.getunleash.Unleash; 4 | import io.getunleash.UnleashContext; 5 | import io.getunleash.Variant; 6 | import org.aopalliance.intercept.MethodInterceptor; 7 | import org.aopalliance.intercept.MethodInvocation; 8 | import org.jetbrains.annotations.NotNull; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | import org.springframework.aop.framework.Advised; 12 | import org.springframework.aop.support.AopUtils; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | import org.springframework.context.ApplicationContext; 15 | import org.springframework.core.annotation.AnnotatedElementUtils; 16 | import org.springframework.stereotype.Component; 17 | import org.springframework.stereotype.Repository; 18 | import org.springframework.stereotype.Service; 19 | import org.springframework.util.CollectionUtils; 20 | import org.springframework.util.StringUtils; 21 | import org.unleash.features.UnleashContextPreProcessor; 22 | import org.unleash.features.annotation.FeatureVariant; 23 | import org.unleash.features.annotation.FeatureVariants; 24 | import org.unleash.features.annotation.Toggle; 25 | 26 | import java.lang.reflect.Method; 27 | import java.util.Arrays; 28 | import java.util.List; 29 | import java.util.Optional; 30 | import java.util.function.Supplier; 31 | 32 | @Component("feature.advisor") 33 | public class FeatureAdvisor implements MethodInterceptor { 34 | @SuppressWarnings("unused") 35 | private static final Logger LOGGER = LoggerFactory.getLogger(FeatureAdvisor.class); 36 | private final Unleash unleash; 37 | private final ApplicationContext applicationContext; 38 | 39 | @SuppressWarnings("SpringJavaAutowiredFieldsWarningInspection") 40 | @Autowired(required = false) 41 | private List contextPreProcessors; 42 | 43 | public FeatureAdvisor(final Unleash unleash, final ApplicationContext applicationContext) { 44 | this.unleash = unleash; 45 | this.applicationContext = applicationContext; 46 | } 47 | 48 | @Override 49 | public Object invoke(@NotNull final MethodInvocation mi) throws Throwable { 50 | final Toggle toggle = getToggleAnnotation(mi); 51 | 52 | if(toggle != null) { 53 | final var variants = toggle.variants(); 54 | final String alterBean = toggle.alterBean(); 55 | final boolean usingAlterBean = StringUtils.hasText(alterBean); 56 | final String executedBeanName = getExecutedBeanName(mi); 57 | 58 | if ((variants == null || variants.variants().length == 0) && alterBean.equals(executedBeanName)) { 59 | return invokePreProcessors(() -> invokeMethodInvocation(mi)); 60 | } 61 | 62 | return invokePreProcessors(() -> checkForFeatureToggle(mi, toggle, alterBean, usingAlterBean, executedBeanName)); 63 | } 64 | 65 | return mi.proceed(); 66 | } 67 | 68 | private Object checkForFeatureToggle(@NotNull final MethodInvocation mi, 69 | final Toggle toggle, 70 | final String alterBean, 71 | final boolean usingAlterBean, 72 | final String executedBeanName) { 73 | 74 | final Optional contextOpt; 75 | final var arguments = mi.getArguments(); 76 | final boolean isFeatureToggled; 77 | final String variantBeanName; 78 | 79 | //If UnleashContext is explicitly passed as a parameter, it takes precedence over the annotation. 80 | contextOpt = Arrays.stream(arguments) 81 | .filter(a -> a instanceof UnleashContext) 82 | .map(a -> (UnleashContext) a) 83 | .findFirst(); 84 | 85 | isFeatureToggled = check(toggle, contextOpt); 86 | 87 | if(isFeatureToggled) { 88 | variantBeanName = toggle.variants().variants().length > 0 ? getVariantBeanName(toggle.name(), toggle.variants(), contextOpt) : null; 89 | 90 | if(!StringUtils.hasText(variantBeanName) && toggle.variants().variants().length > 1) { 91 | LOGGER.warn("Variants present in toggle annotation, but no variants present for feature. Falling back to the default bean"); 92 | return invokeMethodInvocation(mi); 93 | } if(usingAlterBean && !StringUtils.hasText(variantBeanName)) { 94 | return invokeAlterBean(mi, alterBean); 95 | } else if (StringUtils.hasText(variantBeanName)) { 96 | if(variantBeanName.equals(executedBeanName)) { 97 | return invokeMethodInvocation(mi); 98 | } 99 | return invokeAlterBean(mi, variantBeanName); 100 | } else { 101 | throw new IllegalArgumentException("alterClass not yet supported"); 102 | } 103 | } else { 104 | return invokeMethodInvocation(mi); 105 | } 106 | } 107 | 108 | @SuppressWarnings("ConstantConditions") 109 | private String getVariantBeanName(final String featureName, final FeatureVariants featureVariants, final Optional contextOpt) { 110 | final String alterBean; 111 | final Variant variant = contextOpt.map(context -> unleash.getVariant(featureName, context)).orElse(unleash.getVariant(featureName)); 112 | final var featureVariantList = featureVariants.variants(); 113 | 114 | if(variant != null && variant.isEnabled()) { 115 | final Optional featureVariantOpt = Arrays.stream(featureVariantList).filter(featureVariant -> featureVariant.name().equals(variant.getName())).findAny(); 116 | 117 | alterBean = featureVariantOpt.map(FeatureVariant::variantBean).orElseGet(() -> { 118 | LOGGER.warn(String.format("No bean defined for %s in the @FeatureVariants annotation. FallbackBean %s being used", variant.getName(), featureVariants.fallbackBean())); 119 | return featureVariants.fallbackBean(); 120 | }); 121 | 122 | if(!StringUtils.hasText(alterBean)) { 123 | throw new IllegalArgumentException(String.format("No bean or fallback defined for %s in the @FeatureVariants annotation", variant.getName())); 124 | } 125 | } else { 126 | alterBean = null; 127 | } 128 | 129 | return alterBean; 130 | } 131 | 132 | private Object invokeMethodInvocation(final MethodInvocation methodInvocation) { 133 | try { 134 | return methodInvocation.proceed(); 135 | } catch (final RuntimeException ex) { 136 | throw ex; 137 | } catch (Throwable e) { 138 | throw new RuntimeException(e); 139 | } 140 | } 141 | 142 | private Object invokePreProcessors(final Supplier supplier) { 143 | Supplier returnValue = supplier; 144 | 145 | if(!CollectionUtils.isEmpty(contextPreProcessors)) { 146 | for (final UnleashContextPreProcessor contextPreProcessor : contextPreProcessors) { 147 | returnValue = contextPreProcessor.preProcess(supplier); 148 | } 149 | } 150 | 151 | return returnValue.get(); 152 | } 153 | 154 | private Object invokeAlterBean(final MethodInvocation mi, final String alterBeanName) { 155 | final Method method = mi.getMethod(); 156 | 157 | try { 158 | final Object alterBean = applicationContext.getBean(alterBeanName); 159 | 160 | return method.invoke(alterBean, mi.getArguments()); 161 | } catch (Exception e) { 162 | Throwable cause = e.getCause(); 163 | if (cause instanceof RuntimeException) { 164 | throw (RuntimeException) cause; 165 | } else if (cause instanceof Error) { 166 | throw (Error) cause; 167 | } else { 168 | throw new RuntimeException(cause); 169 | } 170 | } 171 | } 172 | 173 | private boolean check(final Toggle toggle, final Optional contextOpt) { 174 | final var featureId = toggle.name(); 175 | 176 | return contextOpt 177 | .map(context -> unleash.isEnabled(featureId, context)) 178 | .orElseGet(() -> unleash.isEnabled(featureId)); 179 | } 180 | 181 | private String getExecutedBeanName(final MethodInvocation mi) { 182 | final Class targetClass = getExecutedClass(mi); 183 | final Component component = targetClass.getAnnotation(Component.class); 184 | 185 | if(component != null) { 186 | return component.value(); 187 | } 188 | 189 | final Service service = targetClass.getAnnotation(Service.class); 190 | 191 | if(service != null) { 192 | return service.value(); 193 | } 194 | 195 | final Repository repository = targetClass.getAnnotation(Repository.class); 196 | 197 | if(repository != null) { 198 | return repository.value(); 199 | } 200 | 201 | try { 202 | for(final String beanName: applicationContext.getBeanDefinitionNames()) { 203 | Object bean = applicationContext.getBean(beanName); 204 | 205 | if(AopUtils.isJdkDynamicProxy(bean)) { 206 | bean = ((Advised)bean).getTargetSource().getTarget(); 207 | } 208 | 209 | if(bean != null && bean.getClass().isAssignableFrom(targetClass)) { 210 | return beanName; 211 | } 212 | } 213 | } catch (final Exception e) { 214 | throw new RuntimeException(e); 215 | } 216 | 217 | throw new IllegalArgumentException("Cannot read behind proxy target"); 218 | } 219 | 220 | private Toggle getToggleAnnotation(final MethodInvocation mi) { 221 | final Method method = mi.getMethod(); 222 | final Class currentInterface; 223 | final Class currentImplementation; 224 | 225 | if(AnnotatedElementUtils.hasAnnotation(method, Toggle.class)) { 226 | return AnnotatedElementUtils.findMergedAnnotation(method, Toggle.class); 227 | } 228 | 229 | currentInterface = method.getDeclaringClass(); 230 | 231 | if(AnnotatedElementUtils.hasAnnotation(currentInterface, Toggle.class)) { 232 | return AnnotatedElementUtils.findMergedAnnotation(currentInterface, Toggle.class); 233 | } 234 | 235 | currentImplementation = getExecutedClass(mi); 236 | 237 | if(AnnotatedElementUtils.hasAnnotation(currentImplementation, Toggle.class)) { 238 | return AnnotatedElementUtils.findMergedAnnotation(currentImplementation, Toggle.class); 239 | } 240 | 241 | return null; 242 | } 243 | 244 | private Class getExecutedClass(final MethodInvocation mi) { 245 | final Class executedClass; 246 | final Object ref = mi.getThis(); 247 | 248 | if(ref != null) { 249 | executedClass = AopUtils.getTargetClass(ref); 250 | } else { 251 | executedClass = null; 252 | } 253 | 254 | if(executedClass == null) { 255 | throw new IllegalArgumentException("Static methods cannot feature feature flipping"); 256 | } 257 | 258 | return executedClass; 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /springboot-unleash-core/src/main/java/org/unleash/features/aop/FeatureProxyAdvisor.java: -------------------------------------------------------------------------------- 1 | package org.unleash.features.aop; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | import org.springframework.aop.TargetSource; 5 | import org.springframework.aop.framework.autoproxy.AbstractAutoProxyCreator; 6 | import org.springframework.core.annotation.AnnotatedElementUtils; 7 | import org.springframework.stereotype.Component; 8 | import org.springframework.util.ClassUtils; 9 | import org.unleash.features.annotation.Toggle; 10 | 11 | import java.lang.reflect.Method; 12 | import java.util.HashMap; 13 | import java.util.Map; 14 | 15 | @SuppressWarnings("MissingSerialAnnotation") 16 | @Component("feature.autoproxy") 17 | public class FeatureProxyAdvisor extends AbstractAutoProxyCreator { 18 | /** Serial number. */ 19 | private static final long serialVersionUID = -364406999854610869L; 20 | 21 | /** Cache to avoid two-passes on same interfaces. */ 22 | private final Map processedInterface = new HashMap<>(); 23 | 24 | 25 | /** 26 | * Default constructor invoked by spring. 27 | */ 28 | public FeatureProxyAdvisor() { 29 | // Define scanner for classes at startup 30 | setInterceptorNames(getBeanNameOfFeatureAdvisor()); 31 | } 32 | 33 | /** 34 | * Read advisor bean name. 35 | * 36 | * @return 37 | * id of {@link FeatureAdvisor} bean 38 | */ 39 | private String getBeanNameOfFeatureAdvisor() { 40 | return FeatureAdvisor.class.getAnnotation(Component.class).value(); 41 | } 42 | 43 | /** {@inheritDoc} */ 44 | @SuppressWarnings({"ConstantConditions", "deprecation"}) 45 | @Override 46 | protected Object[] getAdvicesAndAdvisorsForBean(final Class beanClass, @NotNull final String beanName, final TargetSource targetSource) { 47 | // Do not use any AOP here as still working with classes and not objects 48 | if (!beanClass.isInterface()) { 49 | final Class[] interfaces; 50 | if (ClassUtils.isCglibProxyClass(beanClass)) { 51 | interfaces = beanClass.getSuperclass().getInterfaces(); 52 | } else { 53 | interfaces = beanClass.getInterfaces(); 54 | } 55 | if (interfaces != null) { 56 | for (Class currentInterface: interfaces) { 57 | final Object[] r = scanInterface(currentInterface); 58 | if (r != null) { 59 | return r; 60 | } 61 | } 62 | } 63 | } 64 | return DO_NOT_PROXY; 65 | } 66 | 67 | /** 68 | * Add current annotated interface. 69 | * 70 | * @param currentInterface 71 | * class to be scanned 72 | * @return list of proxies 73 | */ 74 | private Object[] scanInterface(final Class currentInterface) { 75 | final String currentInterfaceName = currentInterface.getCanonicalName(); 76 | final Boolean isInterfaceFlipped; 77 | // Do not scan internals 78 | if (isJdkInterface(currentInterfaceName)) { 79 | return null; 80 | } 81 | // Never scanned, scan first time 82 | if (!processedInterface.containsKey(currentInterfaceName)) { 83 | return scanInterfaceForAnnotation(currentInterface, currentInterfaceName); 84 | } 85 | // Already scanned and flipped do not add interceptors 86 | isInterfaceFlipped = processedInterface.get(currentInterfaceName); 87 | return isInterfaceFlipped ? PROXY_WITHOUT_ADDITIONAL_INTERCEPTORS : null; 88 | } 89 | 90 | /** 91 | * Avoid JDK classes. 92 | * 93 | * @param currentInterfaceName Interface name. Checks if the interface is a JDK Dynamic Proxy 94 | * @return check result 95 | */ 96 | private boolean isJdkInterface(final String currentInterfaceName) { 97 | return currentInterfaceName.startsWith("java."); 98 | } 99 | 100 | private Object[] scanInterfaceForAnnotation(final Class currentInterface, final String currentInterfaceName) { 101 | // Interface never scan 102 | if (AnnotatedElementUtils.hasAnnotation(currentInterface, Toggle.class)) { 103 | processedInterface.put(currentInterfaceName, true); 104 | return PROXY_WITHOUT_ADDITIONAL_INTERCEPTORS; 105 | 106 | } else { 107 | // not found on bean, check methods 108 | for (final Method method : currentInterface.getDeclaredMethods()) { 109 | if (AnnotatedElementUtils.hasAnnotation(method, Toggle.class)) { 110 | processedInterface.put(currentInterfaceName, true); 111 | return PROXY_WITHOUT_ADDITIONAL_INTERCEPTORS; 112 | } 113 | } 114 | } 115 | // annotation has not been found 116 | processedInterface.put(currentInterfaceName, false); 117 | return null; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /springboot-unleash-core/src/main/java/org/unleash/features/aop/UnleashContextThreadLocal.java: -------------------------------------------------------------------------------- 1 | package org.unleash.features.aop; 2 | 3 | import io.getunleash.UnleashContext; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | 7 | import java.util.Map; 8 | import java.util.concurrent.ConcurrentHashMap; 9 | 10 | public class UnleashContextThreadLocal { 11 | private static final Logger LOGGER = LoggerFactory.getLogger(UnleashContextThreadLocal.class); 12 | private static final ThreadLocal> UNLEASH_CONTEXT_BUILDER_THREAD_LOCAL = ThreadLocal.withInitial(ConcurrentHashMap::new); 13 | 14 | public static void addContextProperty(final String name, final String value) { 15 | final String previousValue = UNLEASH_CONTEXT_BUILDER_THREAD_LOCAL.get().putIfAbsent(name, value); 16 | 17 | if(previousValue != null) { 18 | LOGGER.trace("Attribute for {} already present", name); 19 | } 20 | } 21 | 22 | public static UnleashContext get() { 23 | final Map contextMap = UNLEASH_CONTEXT_BUILDER_THREAD_LOCAL.get(); 24 | final UnleashContext.Builder builder = UnleashContext.builder(); 25 | 26 | if(!contextMap.isEmpty()) { 27 | contextMap.forEach((name, value) -> Utils.setContextBuilderProperty(builder, name, value)); 28 | } 29 | 30 | 31 | return builder.build(); 32 | } 33 | 34 | public static Map getContextMap() { 35 | return UNLEASH_CONTEXT_BUILDER_THREAD_LOCAL.get(); 36 | } 37 | 38 | public static void unset() { 39 | UNLEASH_CONTEXT_BUILDER_THREAD_LOCAL.remove(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /springboot-unleash-core/src/main/java/org/unleash/features/aop/Utils.java: -------------------------------------------------------------------------------- 1 | package org.unleash.features.aop; 2 | 3 | import io.getunleash.UnleashContext; 4 | 5 | public final class Utils { 6 | @SuppressWarnings("EnhancedSwitchMigration") 7 | // Not using enhanced switch to keep Java 11 compatibility 8 | public static void setContextBuilderProperty(final UnleashContext.Builder builder, final String name, final String value) { 9 | switch (name) { 10 | case "environment": 11 | builder.environment(value); 12 | break; 13 | case "appName": 14 | builder.appName(value); 15 | break; 16 | case "userId": 17 | builder.userId(value); 18 | break; 19 | case "sessionId": 20 | builder.sessionId(value); 21 | break; 22 | case "remoteAddress": 23 | builder.remoteAddress(value); 24 | break; 25 | default: 26 | builder.addProperty(name, value); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /springboot-unleash-starter/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | unleash-starter 5 | io.getunleash 6 | 1.2.3 7 | 8 | 4.0.0 9 | Unleash Spring Boot starter 10 | https://github.com/unleash/unleash-spring-boot-starter 11 | springboot-unleash-starter 12 | Unleash Spring Boot Starter 13 | 14 | 17 15 | 17 16 | UTF-8 17 | 18 | 19 | 20 | 21 | io.getunleash 22 | springboot-unleash-core 23 | ${project.version} 24 | 25 | 26 | io.getunleash 27 | springboot-unleash-autoconfigure 28 | ${project.version} 29 | 30 | 31 | 32 | 33 | 34 | 35 | org.apache.maven.plugins 36 | maven-compiler-plugin 37 | 3.10.1 38 | 39 | true 40 | 17 41 | 42 | 43 | org.apache.maven.plugins 44 | maven-source-plugin 45 | 3.2.1 46 | 47 | 48 | attach-sources 49 | 50 | jar 51 | 52 | 53 | 54 | 55 | 56 | org.apache.maven.plugins 57 | maven-javadoc-plugin 58 | 3.4.1 59 | 60 | private 61 | true 62 | 17 63 | 64 | 65 | 66 | attach-javadocs 67 | 68 | jar 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | --------------------------------------------------------------------------------