├── .github ├── stale.yml └── workflows │ ├── add-to-project.yml │ └── pull_request.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── bin ├── console ├── setup └── unleash-client ├── echo_client_spec_version.rb ├── examples ├── bootstrap.rb ├── default-toggles.json ├── extending_unleash_with_opentelemetry.rb └── simple.rb ├── lib ├── unleash.rb └── unleash │ ├── bootstrap │ ├── configuration.rb │ ├── handler.rb │ └── provider │ │ ├── base.rb │ │ ├── from_file.rb │ │ └── from_url.rb │ ├── client.rb │ ├── configuration.rb │ ├── context.rb │ ├── metrics_reporter.rb │ ├── scheduled_executor.rb │ ├── spec_version.rb │ ├── strategies.rb │ ├── toggle_fetcher.rb │ ├── util │ └── http.rb │ ├── variant.rb │ └── version.rb ├── spec ├── spec_helper.rb ├── unleash │ ├── bootstrap-resources │ │ └── features-v1.json │ ├── bootstrap │ │ ├── handler_spec.rb │ │ └── provider │ │ │ ├── from_file_spec.rb │ │ │ └── from_url_spec.rb │ ├── client_spec.rb │ ├── client_specification_spec.rb │ ├── configuration_spec.rb │ ├── context_spec.rb │ ├── metrics_reporter_spec.rb │ ├── scheduled_executor_spec.rb │ └── toggle_fetcher_spec.rb └── unleash_spec.rb ├── unleash-client.gemspec └── v6_MIGRATION_GUIDE.md /.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/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | lint: 9 | name: RuboCop 10 | timeout-minutes: 30 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Set up Ruby 15 | uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: "3.0" 18 | bundler-cache: true 19 | - name: Run RuboCop 20 | run: bundle exec rubocop 21 | 22 | test: 23 | runs-on: ${{ matrix.os }}-latest 24 | 25 | strategy: 26 | matrix: 27 | os: 28 | - ubuntu 29 | - macos 30 | - windows 31 | ruby-version: 32 | - jruby-9.4 33 | - 3.4 34 | - 3.3 35 | - 3.2 36 | - 3.1 37 | - '3.0' 38 | - 2.7 39 | exclude: 40 | - os: windows 41 | ruby-version: jruby-9.4 42 | 43 | needs: 44 | - lint 45 | steps: 46 | - uses: actions/checkout@v4 47 | - name: Set up Ruby ${{ matrix.ruby-version }} 48 | uses: ruby/setup-ruby@v1 49 | with: 50 | bundler-cache: true 51 | ruby-version: ${{ matrix.ruby-version }} 52 | - name: Install dependencies 53 | run: bundle install 54 | - name: Get test project semver 55 | id: get_semver 56 | shell: bash 57 | run: | 58 | semver=$(ruby echo_client_spec_version.rb) 59 | echo "::set-output name=semver::$semver" 60 | - name: Download test cases 61 | run: git clone --depth 5 --branch v${{ steps.get_semver.outputs.semver }} https://github.com/Unleash/client-specification.git client-specification 62 | shell: bash 63 | - name: Run tests 64 | run: bundle exec rake 65 | env: 66 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} 67 | - name: Coveralls Parallel 68 | uses: coverallsapp/github-action@master 69 | with: 70 | github-token: ${{ secrets.GITHUB_TOKEN }} 71 | flag-name: run-${{ matrix.test_number }} 72 | parallel: true 73 | 74 | finish: 75 | needs: test 76 | runs-on: ubuntu-latest 77 | steps: 78 | - name: Coveralls Finished 79 | uses: coverallsapp/github-action@master 80 | with: 81 | github-token: ${{ secrets.GITHUB_TOKEN }} 82 | parallel-finished: true 83 | 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /vendor 10 | 11 | # IntelliJ 12 | .idea/ 13 | 14 | # rspec failure tracking 15 | .rspec_status 16 | Gemfile.lock 17 | 18 | # Clone of the client-specification 19 | /client-specification/ 20 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require 'spec_helper' 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # inherit_from: .rubocop_todo.yml 2 | 3 | AllCops: 4 | TargetRubyVersion: 2.7 5 | 6 | Naming/PredicateName: 7 | AllowedMethods: 8 | - is_enabled? 9 | - is_disabled? 10 | 11 | 12 | Metrics/ClassLength: 13 | Max: 135 14 | CountAsOne: 15 | - 'method_call' 16 | Exclude: 17 | - 'lib/unleash/feature_toggle.rb' 18 | Layout/LineLength: 19 | Max: 140 20 | Metrics/MethodLength: 21 | Max: 20 22 | Metrics/BlockLength: 23 | Max: 110 24 | Exclude: 25 | - 'spec/**/*.rb' 26 | 27 | Metrics/AbcSize: 28 | Max: 30 29 | Metrics/CyclomaticComplexity: 30 | Max: 10 31 | Metrics/PerceivedComplexity: 32 | Max: 10 33 | 34 | Style/Documentation: 35 | Enabled: false 36 | 37 | Style/StringLiterals: 38 | Enabled: false 39 | Style/RedundantSelf: 40 | Enabled: false 41 | 42 | Style/OptionalBooleanParameter: 43 | Enabled: false 44 | 45 | Style/SymbolArray: 46 | EnforcedStyle: brackets 47 | Style/WordArray: 48 | EnforcedStyle: brackets 49 | Style/PreferredHashMethods: 50 | EnforcedStyle: verbose 51 | Style/FrozenStringLiteralComment: 52 | EnforcedStyle: never 53 | Style/GuardClause: 54 | MinBodyLength: 8 55 | 56 | Style/HashEachMethods: 57 | Enabled: true 58 | Style/HashTransformKeys: 59 | Enabled: true 60 | Style/HashTransformValues: 61 | Enabled: true 62 | Style/EmptyElse: 63 | Exclude: 64 | - 'lib/unleash/strategy/flexible_rollout.rb' 65 | 66 | Style/DoubleNegation: 67 | Enabled: false 68 | 69 | Style/IfInsideElse: 70 | Exclude: 71 | - 'bin/unleash-client' 72 | 73 | Style/Next: 74 | Exclude: 75 | - 'lib/unleash/scheduled_executor.rb' 76 | 77 | 78 | Style/AccessorGrouping: 79 | Enabled: true 80 | Style/BisectedAttrAccessor: 81 | Enabled: true 82 | Style/CaseLikeIf: 83 | Enabled: true 84 | #Style/ClassEqualityComparison: 85 | # Enabled: true 86 | Style/CombinableLoops: 87 | Enabled: true 88 | Style/ExplicitBlockArgument: 89 | Enabled: true 90 | Style/ExponentialNotation: 91 | Enabled: true 92 | #Style/GlobalStdStream: 93 | # Enabled: true 94 | Style/HashAsLastArrayItem: 95 | Enabled: true 96 | Style/HashLikeCase: 97 | Enabled: true 98 | Style/KeywordParametersOrder: 99 | Enabled: true 100 | #Style/OptionalBooleanParameter: 101 | # Enabled: false 102 | Style/RedundantAssignment: 103 | Enabled: true 104 | Style/RedundantFetchBlock: 105 | Enabled: true 106 | Style/RedundantFileExtensionInRequire: 107 | Enabled: true 108 | Style/RedundantRegexpCharacterClass: 109 | Enabled: true 110 | Style/RedundantRegexpEscape: 111 | Enabled: true 112 | Style/RedundantSelfAssignment: 113 | Enabled: true 114 | Style/SingleArgumentDig: 115 | Enabled: true 116 | Style/SlicingWithRange: 117 | Enabled: true 118 | Style/SoleNestedConditional: 119 | Enabled: true 120 | Style/StringConcatenation: 121 | Enabled: false 122 | Style/TrailingCommaInHashLiteral: 123 | Enabled: true 124 | # EnforcedStyleForMultiline: consistent_comma 125 | 126 | Layout/BeginEndAlignment: 127 | Enabled: true 128 | Layout/EmptyLinesAroundAttributeAccessor: 129 | Enabled: true 130 | Layout/FirstHashElementIndentation: 131 | EnforcedStyle: consistent 132 | Layout/SpaceAroundMethodCallOperator: 133 | Enabled: true 134 | Layout/MultilineMethodCallIndentation: 135 | EnforcedStyle: indented 136 | 137 | Layout/SpaceBeforeBlockBraces: 138 | EnforcedStyle: no_space 139 | Exclude: 140 | - 'unleash-client.gemspec' 141 | - 'spec/**/*.rb' 142 | 143 | Lint/BinaryOperatorWithIdenticalOperands: 144 | Enabled: true 145 | Lint/ConstantDefinitionInBlock: 146 | Enabled: false 147 | Lint/DeprecatedOpenSSLConstant: 148 | Enabled: true 149 | Lint/DuplicateElsifCondition: 150 | Enabled: true 151 | Lint/DuplicateRequire: 152 | Enabled: true 153 | Lint/DuplicateRescueException: 154 | Enabled: true 155 | Lint/EmptyConditionalBody: 156 | Enabled: true 157 | Lint/EmptyFile: 158 | Enabled: true 159 | Lint/FloatComparison: 160 | Enabled: true 161 | Lint/HashCompareByIdentity: 162 | Enabled: true 163 | Lint/IdentityComparison: 164 | Enabled: true 165 | Lint/MissingSuper: 166 | Enabled: false 167 | Lint/MixedRegexpCaptureTypes: 168 | Enabled: true 169 | Lint/OutOfRangeRegexpRef: 170 | Enabled: true 171 | Lint/RaiseException: 172 | Enabled: true 173 | Lint/RedundantSafeNavigation: 174 | Enabled: true 175 | Lint/SelfAssignment: 176 | Enabled: true 177 | Lint/StructNewOverride: 178 | Enabled: true 179 | Lint/TopLevelReturnWithArgument: 180 | Enabled: true 181 | Lint/TrailingCommaInAttributeDeclaration: 182 | Enabled: true 183 | Lint/UnreachableLoop: 184 | Enabled: true 185 | Lint/UselessMethodDefinition: 186 | Enabled: true 187 | Lint/UselessTimes: 188 | Enabled: true 189 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # Changelog 3 | 4 | All notable changes to this project will be documented in this file. 5 | 6 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 7 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 8 | 9 | Note: These changes are not considered notable: 10 | - build 11 | - documentation 12 | - dependencies 13 | 14 | ## [Unreleased] 15 | 16 | ## [6.3.1] - 2025-05-27 17 | ### Fixed 18 | - Upgraded Yggdrasil engine to fix a memory leak in metrics. 19 | 20 | ## [6.3.0] - 2025-04-22 21 | ### Changed 22 | - Updated `logger` dependency to `~> 1.6` in the gemspec to allow compatibility with logger versions above 1.6. 23 | 24 | ## [6.2.1] - 2025-04-14 25 | ### Fixed 26 | - metrics sending no longer fails after 10 minutes 27 | 28 | ## [6.2.0] - 2024-02-28 29 | ### Added 30 | - unleash-interval header (#236) 31 | - connectionId in metrics and registration payload (#236) 32 | - default interval changed from 10s to 15s (#236) 33 | - update Yggdrasil engine (#228) 34 | - delta API configuration option (#228) 35 | 36 | ## [6.1.2] - 2024-01-30 37 | ### Fixed 38 | - drop x- header prefix (#229) 39 | 40 | ## [6.1.1] - 2024-01-21 41 | ### Fixed 42 | - use existing sdk name convention (#226) 43 | 44 | ## [6.1.0] - 2025-01-21 45 | ### Added 46 | - standardised client identification headers (#224) 47 | 48 | 49 | ## [6.0.10] - 2024-12-19 50 | ### Fixed 51 | - Fixed an edge case issue where the client was failing to send metrics after 10 minutes of not having metrics (#219) 52 | 53 | ## [6.0.9] - 2024-11-21 54 | ### Fixed 55 | - Fixed an issue where the server not sending the encoding would break file saving (#215) 56 | 57 | ## [6.0.8] - 2024-11-13 58 | ### Fixed 59 | - Fixed an issue where the SDK running on aarch64 wouldn't load the binary correctly 60 | 61 | ## [6.0.7] - 2024-10-24 62 | #### Fixed 63 | - Context object correctly dumps to JSON 64 | 65 | ## [6.0.6] - 2024-10-23 66 | #### Changed 67 | - Upgrade core engine library to allow ffi version 1.16.3 68 | 69 | ## [6.0.5] - 2024-09-25 70 | #### Fixed 71 | - Upgrade core engine library to support ARM on legacy Mac 72 | 73 | ## [6.0.0] - 2024-09-25 74 | #### Fixed 75 | - Upgrade core engine library to support ARM on Linux 76 | 77 | ## [6.0.0.pre] - 2024-09-25 78 | #### Changed 79 | - No longer possible to override built in strategies with custom strategies (#152) 80 | - No longer possible to access built in strategy's objects, these are now native code (#152) 81 | - Core of the SDK swapped for Yggdrasil engine (#152) 82 | 83 | ## [5.1.1] - 2024-09-23 84 | ### Fixed 85 | - increased accuracy of rollout distribution (#200) 86 | 87 | ## [5.1.0] - 2024-09-18 88 | ### Added 89 | - feature_enabled in variants (#197) 90 | 91 | ### Fixed 92 | - fix issue with strategy variant stickiness (#198) 93 | 94 | ## [5.0.7] - 2024-09-04 95 | ### Changed 96 | - segments now work with variants (#194) 97 | 98 | ## [5.0.6] - 2024-08-29 99 | ### Changed 100 | - do not fail when case insentive enabled while having a complex context object (#191) 101 | 102 | ## [5.0.5] - 2024-07-31 103 | ### Changed 104 | - emit warning when overriding a built in strategy (#187) 105 | 106 | ## [5.0.4] - 2024-07-15 107 | ### Changed 108 | - Reverted "feat: automatically generated instance_id (#179)" (#185) 109 | 110 | ## [5.0.3] - 2024-07-10 111 | ### Fixed 112 | - Client spec version should be loaded without touching the gemspec 113 | 114 | ## [5.0.2] - 2024-07-05 115 | ### Changed 116 | - metrics data now includes information about the core engine (#178) 117 | - to_s method on the context object now includes current time (#175) 118 | - drop support for MRI 2.5 and JRuby 9.2 (#174) 119 | - current_time property on the context now handles DateTimes as well as strings (#173) 120 | 121 | ## [5.0.1] - 2024-03-27 122 | ### Changed 123 | - make user-agent headers more descriptive (#168) 124 | 125 | ### Fixed 126 | - make client more resilient to non-conforming responses from `unleash-edge` (#162) 127 | - while the unleash server provides always valid responses, (at least some versions of) unleash-edge can provide an unexpected JSON response (null instead of empty array). 128 | - fixed the handling of the response, so we do not throw exceptions in this situation. 129 | 130 | ## [5.0.0] - 2023-10-30 131 | ### Added 132 | - change seed for variantutils to ensure fair distribution (#160) 133 | - client specification is [here](https://github.com/Unleash/client-specification/tree/v5.0.2/specifications) 134 | - A new seed is introduced to ensure a fair distribution for variants, addressing the issue of skewed variant distribution due to using the same hash string for both gradual rollout and variant allocation. 135 | 136 | ## [4.6.0] - 2023-10-16 137 | ### Added 138 | - dependant toggles (#155) 139 | - client specification is [here](https://github.com/Unleash/client-specification/pull/63) 140 | 141 | ## [4.5.0] - 2023-07-05 142 | ### Added 143 | - variants in strategies (#148) 144 | - issue described here (#147) 145 | 146 | ### Fixed 147 | - groupId override for variants 148 | 149 | ## [4.4.4] - 2023-07-05 150 | ### Fixed 151 | - flexible rollout strategy without context (#146) 152 | - The flexible rollout strategy should evaluate default and random stickiness even if context is not provided. 153 | 154 | ## [4.4.3] - 2023-06-14 155 | ### Added 156 | - Add Context#to_h method (#136) 157 | 158 | ### Fixed 159 | - Bootstrapped variants now work when client is disabled (#138) 160 | - Variant metrics are now sent correctly to Unleash. Fixed a typo in the payload name. (#145) 161 | 162 | ### Changed 163 | - Automatically disable metrics/MetricsReporter when client is disabled (#139) (#140) 164 | 165 | ## [4.4.2] - 2023-01-05 166 | ### Added 167 | - Add Client#disabled? method (#130) 168 | 169 | ## [4.4.1] - 2022-12-07 170 | ### Fixed 171 | - exception no longer bubbles up in constraints when context is nil (#127) 172 | - variants metrics did count toggles correctly (#126) 173 | - prevent race condition when manipulating metrics data (#122) 174 | - allow passing user_id as integer (#119) 175 | 176 | ## [4.4.0] - 2022-09-19 177 | ### Added 178 | - Allow custom strategies (#96) 179 | - Global segments (#114) 180 | 181 | ### Fixed 182 | - Initializing client configuration from constructor (#117) 183 | - Support int context in set comparison (#115) 184 | 185 | ## [4.3.0] - 2023-07-14 186 | ### Added 187 | - dynamic http headers via Proc or Lambda (#107) 188 | 189 | ### Fixed 190 | - Fixed ToggleFetcher#save! to close opened files on failure. (#97) 191 | 192 | ### Changed 193 | - Refactored ToggleFetcher#read! (#106) 194 | 195 | ## [4.2.1] - 2022-03-29 196 | ### Fixed 197 | - correct logic for default values on feature toggles so toggle value respected when toggle exists and default is true (#93) 198 | 199 | ## [4.2.0] - 2022-03-18 200 | ### Added 201 | - Advanced constraints operators (#92) 202 | 203 | ### Changed 204 | - Default to the client never giving up trying to reach the server even after repeated failures (#91) 205 | 206 | ## [4.1.0] - 2022-02-11 207 | ### Added 208 | - feat: Implement custom bootstrapping on startup (#88) 209 | - feat: add support for cidr in `RemoteAddress` strategy (#77) 210 | 211 | ### Changed 212 | - default values for `metrics_interval` to `60s` and `retry_limit` to `5` (#78) 213 | 214 | ## [4.0.0] - 2021-12-16 215 | ### Added 216 | - Support for projects query (requires unleash 4.x) (#38) 217 | - Allow passing blocks to is_enabled? to determine default_result (#33) 218 | - Implement custom stickiness (#69) 219 | - Allow using custom_http_headers from the CLI utility (#75) 220 | 221 | ### Fixed 222 | - Allow context to correctly resolve camelCase property values (#74) 223 | - Avoid unlikely situation of config changing under the read operation due to backup path file being incorrectly set (#63) 224 | 225 | ### Changed 226 | - change how we handle the server api url (avoid double slashes in urls used for API calls.) 227 | - default values: refresh_interval => 10, metrics_interval=> 30 (#59) 228 | - changed metrics reporting behavior (#66) 229 | - only send metrics if there is data to send. (#58) 230 | - in Client#get_variant() allow context and fallback_variant as nil (#51) 231 | 232 | [unreleased]: https://github.com/unleash/unleash-client-ruby/compare/v5.0.1...HEAD 233 | [5.0.1]: https://github.com/unleash/unleash-client-ruby/compare/v5.0.0...v5.0.1 234 | [5.0.0]: https://github.com/unleash/unleash-client-ruby/compare/v4.6.0...v5.0.0 235 | [4.6.0]: https://github.com/unleash/unleash-client-ruby/compare/v4.5.0...v4.6.0 236 | [4.5.0]: https://github.com/unleash/unleash-client-ruby/compare/v4.4.4...v4.5.0 237 | [4.4.4]: https://github.com/unleash/unleash-client-ruby/compare/v4.4.3...v4.4.4 238 | [4.4.3]: https://github.com/unleash/unleash-client-ruby/compare/v4.4.2...v4.4.3 239 | [4.4.2]: https://github.com/unleash/unleash-client-ruby/compare/v4.4.1...v4.4.2 240 | [4.4.1]: https://github.com/unleash/unleash-client-ruby/compare/v4.4.0...v4.4.1 241 | [4.4.0]: https://github.com/unleash/unleash-client-ruby/compare/v4.3.0...v4.4.0 242 | [4.3.0]: https://github.com/unleash/unleash-client-ruby/compare/v4.2.1...v4.3.0 243 | [4.2.1]: https://github.com/unleash/unleash-client-ruby/compare/v4.2.0...v4.2.1 244 | [4.2.0]: https://github.com/unleash/unleash-client-ruby/compare/v4.1.0...v4.2.0 245 | [4.1.0]: https://github.com/unleash/unleash-client-ruby/compare/v4.0.0...v4.1.0 246 | [4.0.0]: https://github.com/unleash/unleash-client-ruby/compare/v3.2.5...v4.0.0 247 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in unleash-client.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /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 2018 Renato Arruda 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 | # Unleash::Client 2 | 3 | ![Build Status](https://github.com/Unleash/unleash-client-ruby/actions/workflows/pull_request.yml/badge.svg?branch=main) 4 | [![Coverage Status](https://coveralls.io/repos/github/Unleash/unleash-client-ruby/badge.svg?branch=main)](https://coveralls.io/github/Unleash/unleash-client-ruby?branch=main) 5 | [![Gem Version](https://badge.fury.io/rb/unleash.svg)](https://badge.fury.io/rb/unleash) 6 | 7 | Ruby client for the [Unleash](https://github.com/Unleash/unleash) feature management service. 8 | 9 | > **Migrating to v6** 10 | > 11 | > If you use [custom strategies](#custom-strategies) or override built-in ones, read the complete [migration guide](./v6_MIGRATION_GUIDE.md) before upgrading to v6. 12 | 13 | 14 | ## Supported Ruby interpreters 15 | 16 | - MRI 3.4 17 | - MRI 3.3 18 | - MRI 3.2 19 | - MRI 3.1 20 | - MRI 3.0 21 | - MRI 2.7 22 | - jruby 9.4 23 | 24 | ## Installation 25 | 26 | Add this line to your application's Gemfile: 27 | 28 | ```ruby 29 | gem 'unleash', '~> 6.3.0' 30 | ``` 31 | 32 | And then execute: 33 | 34 | $ bundle 35 | 36 | Or install it yourself as: 37 | 38 | $ gem install unleash 39 | 40 | ## Configuration 41 | 42 | It is **required** to configure: 43 | 44 | - `app_name` with the name of the running application 45 | - `url` of your Unleash server 46 | - `custom_http_headers` with `{'Authorization': ''}` when using Unleash v4+ 47 | 48 | It is **highly recommended** to configure: 49 | 50 | - `instance_id` parameter with a unique identifier for the running instance 51 | 52 | ```ruby 53 | Unleash.configure do |config| 54 | config.app_name = 'my_ruby_app' 55 | config.url = '/api' 56 | config.custom_http_headers = {'Authorization': ''} 57 | end 58 | ``` 59 | 60 | or instantiate the client with the valid configuration: 61 | 62 | ```ruby 63 | UNLEASH = Unleash::Client.new(url: '/api', app_name: 'my_ruby_app', custom_http_headers: {'Authorization': ''}) 64 | ``` 65 | 66 | ## Dynamic custom HTTP headers 67 | 68 | If you need custom HTTP headers that change during the lifetime of the client, you can pass `custom_http_headers` as a `Proc`. 69 | 70 | ```ruby 71 | Unleash.configure do |config| 72 | config.app_name = 'my_ruby_app' 73 | config.url = '/api' 74 | config.custom_http_headers = proc do 75 | { 76 | 'Authorization': '', 77 | 'X-Client-Request-Time': Time.now.iso8601 78 | } 79 | end 80 | end 81 | ``` 82 | 83 | #### List of arguments 84 | 85 | | Argument | Description | Required? | Type | Default value | 86 | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | --------------------------------- | ---------------------------------------------- | 87 | | `url` | Unleash server URL. | Y | String | N/A | 88 | | `app_name` | Name of your program. | Y | String | N/A | 89 | | `instance_id` | Identifier for the running instance of your program—set this to be able trace where metrics are being collected from. | N | String | random UUID | 90 | | `environment` | Unleash context option, for example, `prod` or `dev`. Not yet in use. **Not** the same as the SDK's [Unleash environment](https://docs.getunleash.io/reference/environments). | N | String | `default` | 91 | | `project_name` | Name of the project to retrieve feature flags from. If not set, all feature flags will be retrieved. | N | String | nil | 92 | | `refresh_interval` | How often the Unleash client should check with the server for configuration changes. | N | Integer | 15 | 93 | | `metrics_interval` | How often the Unleash client should send metrics to server. | N | Integer | 60 | 94 | | `disable_client` | Disables all communication with the Unleash server, effectively taking it _offline_. If set, `is_enabled?` always answer with the `default_value` and configuration validation is skipped. Will also forcefully set `disable_metrics` to `true`. Defeats the entire purpose of using Unleash, except when running tests. | N | Boolean | `false` | 95 | | `disable_metrics` | Disables sending metrics to Unleash server. If the `disable_client` option is set to `true`, then this option will also be set to `true`, regardless of the value provided. | N | Boolean | `false` | 96 | | `custom_http_headers` | Custom headers to send to Unleash. As of Unleash v4.0.0, the `Authorization` header is required. For example: `{'Authorization': ''}`. | N | Hash/Proc | {} | 97 | | `timeout` | How long to wait for the connection to be established or wait in reading state (open_timeout/read_timeout) | N | Integer | 30 | 98 | | `retry_limit` | How many consecutive failures in connecting to the Unleash server are allowed before giving up. The default is to retry indefinitely. | N | Float::INFINITY | 5 | 99 | | `backup_file` | Filename to store the last known state from the Unleash server. It is best to not change this from the default. | N | String | `Dir.tmpdir + "/unleash-#{app_name}-repo.json` | 100 | | `logger` | Specify a custom `Logger` class to handle logs for the Unleash client. | N | Class | `Logger.new(STDOUT)` | 101 | | `log_level` | Change the log level for the `Logger` class. Constant from `Logger::Severity`. | N | Constant | `Logger::WARN` | 102 | | `bootstrap_config` | Bootstrap config for loading data on startup—useful for loading large states on startup without (or before) hitting the network. | N | Unleash::Bootstrap::Configuration | `nil` | 103 | | `strategies` | Strategies manager that holds all strategies and allows to add custom strategies. | N | Unleash::Strategies | `Unleash::Strategies.new` | 104 | 105 | For a more in-depth look, please see `lib/unleash/configuration.rb`. 106 | 107 | | Environment Variable | Description | 108 | | ------------------------ | -------------------------------- | 109 | | `UNLEASH_BOOTSTRAP_FILE` | File to read bootstrap data from | 110 | | `UNLEASH_BOOTSTRAP_URL` | URL to read bootstrap data from | 111 | 112 | ## Usage in a plain Ruby application 113 | 114 | ```ruby 115 | require 'unleash' 116 | require 'unleash/context' 117 | 118 | @unleash = Unleash::Client.new(app_name: 'my_ruby_app', url: '/api', custom_http_headers: { 'Authorization': '' }) 119 | 120 | feature_name = "AwesomeFeature" 121 | unleash_context = Unleash::Context.new 122 | unleash_context.user_id = 123 123 | 124 | if @unleash.is_enabled?(feature_name, unleash_context) 125 | puts " #{feature_name} is enabled according to unleash" 126 | else 127 | puts " #{feature_name} is disabled according to unleash" 128 | end 129 | 130 | if @unleash.is_disabled?(feature_name, unleash_context) 131 | puts " #{feature_name} is disabled according to unleash" 132 | else 133 | puts " #{feature_name} is enabled according to unleash" 134 | end 135 | ``` 136 | 137 | ## Usage in a Rails application 138 | 139 | ### 1. Add Initializer 140 | 141 | The initializer setup varies depending on whether you’re using a standard setup, Puma in clustered mode, Phusion Passenger, or Sidekiq. 142 | 143 | #### 1.a Initializer for standard Rails applications 144 | 145 | Put in `config/initializers/unleash.rb`: 146 | 147 | ```ruby 148 | Unleash.configure do |config| 149 | config.app_name = Rails.application.class.module_parent_name 150 | config.url = '' 151 | # config.instance_id = "#{Socket.gethostname}" 152 | config.logger = Rails.logger 153 | config.custom_http_headers = {'Authorization': ''} 154 | end 155 | 156 | UNLEASH = Unleash::Client.new 157 | 158 | # Or if preferred: 159 | # Rails.configuration.unleash = Unleash::Client.new 160 | ``` 161 | 162 | For `config.instance_id` use a string with a unique identification for the running instance. For example, it could be the hostname if you only run one App per host, or the docker container ID, if you are running in Docker. 163 | If not set, the client will generate a unique UUID for each execution. 164 | 165 | To have it available in the `rails console` command as well, also add to the file above: 166 | 167 | ```ruby 168 | Rails.application.console do 169 | UNLEASH = Unleash::Client.new 170 | # or 171 | # Rails.configuration.unleash = Unleash::Client.new 172 | end 173 | ``` 174 | 175 | #### 1.b Add Initializer if using [Puma in clustered mode](https://github.com/puma/puma#clustered-mode) 176 | 177 | That is, multiple workers configured in `puma.rb`: 178 | 179 | ```ruby 180 | workers ENV.fetch("WEB_CONCURRENCY") { 2 } 181 | ``` 182 | 183 | ##### with `preload_app!` 184 | 185 | Then you may keep the client configuration still in `config/initializers/unleash.rb`: 186 | 187 | ```ruby 188 | Unleash.configure do |config| 189 | config.app_name = Rails.application.class.parent.to_s 190 | config.url = '/api' 191 | config.custom_http_headers = {'Authorization': ''} 192 | end 193 | ``` 194 | 195 | But you must ensure that the Unleash client is instantiated only after the process is forked. 196 | This is done by creating the client inside the `on_worker_boot` code block in `puma.rb` as below: 197 | 198 | ```ruby 199 | #... 200 | preload_app! 201 | #... 202 | 203 | on_worker_boot do 204 | # ... 205 | 206 | ::UNLEASH = Unleash::Client.new 207 | end 208 | 209 | on_worker_shutdown do 210 | ::UNLEASH.shutdown 211 | end 212 | ``` 213 | 214 | ##### without `preload_app!` 215 | 216 | By not using `preload_app!`: 217 | 218 | - The `Rails` constant will **not** be available. 219 | - Phased restarts will be possible. 220 | 221 | You need to ensure that in `puma.rb`: 222 | 223 | - The Unleash SDK is loaded with `require 'unleash'` explicitly, as it will not be pre-loaded. 224 | - All parameters are set explicitly in the `on_worker_boot` block, as `config/initializers/unleash.rb` is not read. 225 | - There are no references to `Rails` constant, as that is not yet available. 226 | 227 | Example for `puma.rb`: 228 | 229 | ```ruby 230 | require 'unleash' 231 | 232 | #... 233 | # no preload_app! 234 | 235 | on_worker_boot do 236 | # ... 237 | 238 | ::UNLEASH = Unleash::Client.new( 239 | app_name: 'my_rails_app', 240 | url: '/api', 241 | custom_http_headers: {'Authorization': ''}, 242 | ) 243 | end 244 | 245 | on_worker_shutdown do 246 | ::UNLEASH.shutdown 247 | end 248 | ``` 249 | 250 | Note that we also added shutdown hooks in `on_worker_shutdown`, to ensure a clean shutdown. 251 | 252 | #### 1.c Add Initializer if using [Phusion Passenger](https://github.com/phusion/passenger) 253 | 254 | The Unleash client needs to be configured and instantiated inside the `PhusionPassenger.on_event(:starting_worker_process)` code block due to [smart spawning](https://www.phusionpassenger.com/library/indepth/ruby/spawn_methods/#smart-spawning-caveats): 255 | 256 | The initializer in `config/initializers/unleash.rb` should look like: 257 | 258 | ```ruby 259 | PhusionPassenger.on_event(:starting_worker_process) do |forked| 260 | if forked 261 | Unleash.configure do |config| 262 | config.app_name = Rails.application.class.parent.to_s 263 | # config.instance_id = "#{Socket.gethostname}" 264 | config.logger = Rails.logger 265 | config.url = '/api' 266 | config.custom_http_headers = {'Authorization': ''} 267 | end 268 | 269 | UNLEASH = Unleash::Client.new 270 | end 271 | end 272 | ``` 273 | 274 | #### 1.d Add Initializer hooks when using within [Sidekiq](https://github.com/mperham/sidekiq) 275 | 276 | Note that in this case, we require that the code block for `Unleash.configure` is set beforehand. 277 | For example in `config/initializers/unleash.rb`. 278 | 279 | ```ruby 280 | Sidekiq.configure_server do |config| 281 | config.on(:startup) do 282 | UNLEASH = Unleash::Client.new 283 | end 284 | 285 | config.on(:shutdown) do 286 | UNLEASH.shutdown 287 | end 288 | end 289 | ``` 290 | 291 | ### 2. Set Unleash::Context 292 | 293 | Add the following method and callback in the application controller to have `@unleash_context` set for all requests: 294 | 295 | Add in `app/controllers/application_controller.rb`: 296 | 297 | ```ruby 298 | before_action :set_unleash_context 299 | 300 | private 301 | def set_unleash_context 302 | @unleash_context = Unleash::Context.new( 303 | session_id: session.id, 304 | remote_address: request.remote_ip, 305 | user_id: session[:user_id] 306 | ) 307 | end 308 | ``` 309 | 310 | Alternatively, you can add this method only to the controllers that use Unleash. 311 | 312 | ### 3. Sample usage 313 | 314 | Then wherever in your application that you need a feature toggle, you can use: 315 | 316 | ```ruby 317 | if UNLEASH.is_enabled? "AwesomeFeature", @unleash_context 318 | puts "AwesomeFeature is enabled" 319 | end 320 | ``` 321 | 322 | or if client is set in `Rails.configuration.unleash`: 323 | 324 | ```ruby 325 | if Rails.configuration.unleash.is_enabled? "AwesomeFeature", @unleash_context 326 | puts "AwesomeFeature is enabled" 327 | end 328 | ``` 329 | 330 | If you don't want to check a feature is disabled with `unless`, you can also use `is_disabled?`: 331 | 332 | ```ruby 333 | # so instead of: 334 | unless UNLEASH.is_enabled? "AwesomeFeature", @unleash_context 335 | puts "AwesomeFeature is disabled" 336 | end 337 | 338 | # it might be more intelligible: 339 | if UNLEASH.is_disabled? "AwesomeFeature", @unleash_context 340 | puts "AwesomeFeature is disabled" 341 | end 342 | ``` 343 | 344 | If the feature is not found in the server, it will by default return false. 345 | However, you can override that by setting the default return value to `true`: 346 | 347 | ```ruby 348 | if UNLEASH.is_enabled? "AwesomeFeature", @unleash_context, true 349 | puts "AwesomeFeature is enabled by default" 350 | end 351 | # or 352 | if UNLEASH.is_disabled? "AwesomeFeature", @unleash_context, true 353 | puts "AwesomeFeature is disabled by default" 354 | end 355 | ``` 356 | 357 | Another possibility is to send a block, [Lambda](https://ruby-doc.org/core-3.0.1/Kernel.html#method-i-lambda) or [Proc](https://ruby-doc.org/core-3.0.1/Proc.html#method-i-yield) 358 | to evaluate the default value: 359 | 360 | ```ruby 361 | net_check_proc = proc do |feature_name, context| 362 | context.remote_address.starts_with?("10.0.0.") 363 | end 364 | 365 | if UNLEASH.is_enabled?("AwesomeFeature", @unleash_context, &net_check_proc) 366 | puts "AwesomeFeature is enabled by default if you are in the 10.0.0.* network." 367 | end 368 | ``` 369 | 370 | or 371 | 372 | ```ruby 373 | awesomeness = 10 374 | @unleash_context.properties[:coolness] = 10 375 | 376 | if UNLEASH.is_enabled?("AwesomeFeature", @unleash_context) { |feat, ctx| awesomeness >= 6 && ctx.properties[:coolness] >= 8 } 377 | puts "AwesomeFeature is enabled by default if both the user has a high enough coolness and the application has a high enough awesomeness" 378 | end 379 | ``` 380 | 381 | Note: 382 | 383 | - The block/lambda/proc can use the feature name and context as arguments. 384 | - The client will evaluate the fallback function once per call of `is_enabled()`. 385 | Please keep this in mind when creating your fallback function. 386 | - The returned value of the block should be a boolean. 387 | However, the client will coerce the result to a boolean via `!!`. 388 | - If both a `default_value` and `fallback_function` are supplied, 389 | the client will define the default value by `OR`ing the default value and the output of the fallback function. 390 | 391 | Alternatively by using `if_enabled` (or `if_disabled`) you can send a code block to be executed as a parameter: 392 | 393 | ```ruby 394 | UNLEASH.if_enabled "AwesomeFeature", @unleash_context, true do 395 | puts "AwesomeFeature is enabled by default" 396 | end 397 | ``` 398 | 399 | Note: `if_enabled` (and `if_disabled`) only support `default_value`, but not `fallback_function`. 400 | 401 | #### Variations 402 | 403 | If no flag is found in the server, use the fallback variant. 404 | 405 | ```ruby 406 | fallback_variant = Unleash::Variant.new(name: 'default', enabled: true, payload: {"color" => "blue"}) 407 | variant = UNLEASH.get_variant "ColorVariants", @unleash_context, fallback_variant 408 | 409 | puts "variant color is: #{variant.payload.fetch('color')}" 410 | ``` 411 | 412 | ## Bootstrapping 413 | 414 | Bootstrap configuration allows the client to be initialized with a predefined set of toggle states. 415 | Bootstrapping can be configured by providing a bootstrap configuration when initializing the client. 416 | 417 | ```ruby 418 | @unleash = Unleash::Client.new( 419 | url: '/api', 420 | app_name: 'my_ruby_app', 421 | custom_http_headers: { 'Authorization': '' }, 422 | bootstrap_config: Unleash::Bootstrap::Configuration.new({ 423 | url: "/api/client/features", 424 | url_headers: {'Authorization': ''} 425 | }) 426 | ) 427 | ``` 428 | 429 | The `Bootstrap::Configuration` initializer takes a hash with one of the following options specified: 430 | 431 | - `file_path` - An absolute or relative path to a file containing a JSON string of the response body from the Unleash server. This can also be set through the `UNLEASH_BOOTSTRAP_FILE` environment variable. 432 | - `url` - A url pointing to an Unleash server's features endpoint, the code sample above is illustrative. This can also be set through the `UNLEASH_BOOTSTRAP_URL` environment variable. 433 | - `url_headers` - Headers for the GET HTTP request to the `url` above. Only used if the `url` parameter is also set. If this option isn't set then the bootstrapper will use the same url headers as the Unleash client. 434 | - `data` - A raw JSON string as returned by the Unleash server. 435 | - `block` - A lambda containing custom logic if you need it, an example is provided below. 436 | 437 | You should only specify one type of bootstrapping since only one will be invoked and the others will be ignored. 438 | The order of preference is as follows: 439 | 440 | - Select a data bootstrapper if it exists. 441 | - If no data bootstrapper exists, select the block bootstrapper. 442 | - If no block bootstrapper exists, select the file bootstrapper from either parameters or the specified environment variable. 443 | - If no file bootstrapper exists, then check for a URL bootstrapper from either the parameters or the specified environment variable. 444 | 445 | Example usage: 446 | 447 | First, save the toggles locally: 448 | 449 | ```shell 450 | curl -H 'Authorization: ' -XGET '/api' > ./default-toggles.json 451 | ``` 452 | 453 | Then use them on startup: 454 | 455 | ```ruby 456 | 457 | custom_boostrapper = lambda { 458 | File.read('./default-toggles.json') 459 | } 460 | 461 | @unleash = Unleash::Client.new( 462 | app_name: 'my_ruby_app', 463 | url: '/api', 464 | custom_http_headers: { 'Authorization': '' }, 465 | bootstrap_config: Unleash::Bootstrap::Configuration.new({ 466 | block: custom_boostrapper 467 | }) 468 | ) 469 | ``` 470 | 471 | This example could be easily achieved with a file bootstrapper, this is just to illustrate the usage of custom bootstrapping. 472 | Be aware that the client initializer will block until bootstrapping is complete. 473 | 474 | #### Client methods 475 | 476 | | Method name | Description | Return type | 477 | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | 478 | | `is_enabled?` | Checks if a feature toggle is enabled or not | Boolean | 479 | | `enabled?` | A more idiomatic Ruby alias for the `is_enabled?` method | Boolean | 480 | | `if_enabled` | Runs a code block, if a feature is enabled | `yield` | 481 | | `is_disabled?` | Checks if feature toggle is enabled or not | Boolean | 482 | | `disabled?` | A more idiomatic Ruby alias for the `is_disabled?` method | Boolean | 483 | | `if_disabled` | Runs a code block, if a feature is disabled | `yield` | 484 | | `get_variant` | Gets variant for a given feature | `Unleash::Variant` | 485 | | `shutdown` | Saves metrics to disk, flushes metrics to server, and then kills `ToggleFetcher` and `MetricsReporter` threads—a safe shutdown, not generally needed in long-running applications, like web applications | nil | 486 | | `shutdown!` | Kills `ToggleFetcher` and `MetricsReporter` threads immediately | nil | 487 | 488 | For the full method signatures, see [client.rb](lib/unleash/client.rb). 489 | 490 | ## Local test client 491 | 492 | ``` 493 | # cli unleash client: 494 | bundle exec bin/unleash-client --help 495 | 496 | # or a simple sample implementation (with values hardcoded): 497 | bundle exec examples/simple.rb 498 | ``` 499 | 500 | ## Available strategies 501 | 502 | This client comes with all the required strategies out of the box: 503 | 504 | - ApplicationHostnameStrategy 505 | - DefaultStrategy 506 | - FlexibleRolloutStrategy 507 | - GradualRolloutRandomStrategy 508 | - GradualRolloutSessionIdStrategy 509 | - GradualRolloutUserIdStrategy 510 | - RemoteAddressStrategy 511 | - UnknownStrategy 512 | - UserWithIdStrategy 513 | 514 | ## Custom strategies 515 | 516 | You can add [custom activation strategies](https://docs.getunleash.io/advanced/custom_activation_strategy) using configuration. 517 | In order for the strategy to work correctly it should support two methods `name` and `is_enabled?`. 518 | 519 | ```ruby 520 | class MyCustomStrategy 521 | def name 522 | 'myCustomStrategy' 523 | end 524 | 525 | def is_enabled?(params = {}, context = nil) 526 | true 527 | end 528 | end 529 | 530 | Unleash.configure do |config| 531 | config.strategies.add(MyCustomStrategy.new) 532 | end 533 | ``` 534 | 535 | ## Development 536 | 537 | After checking out the repo, run `bin/setup` to install dependencies. 538 | Then, run `bundle exec rake spec` to run the tests. 539 | You can also run `bin/console` for an interactive prompt that will allow you to experiment. 540 | 541 | This SDK is also built against the Unleash Client Specification tests. 542 | To run the Ruby SDK against this test suite, you'll need to have a copy on your machine, you can clone the repository directly using: 543 | 544 | `git clone --branch v$(ruby echo_client_spec_version.rb) https://github.com/Unleash/client-specification.git` 545 | 546 | After doing this, `bundle exec rake spec` will also run the client specification tests. 547 | 548 | To install this gem onto your local machine, run `bundle exec rake install`. 549 | 550 | ## Releasing 551 | 552 | To release a new version, follow these steps: 553 | 554 | 1. Update version number: 555 | - Increment the version number in the `./lib/unleash/version.rb` file according to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) guidelines. 556 | 2. Update documentation: 557 | - If the update includes a major or minor version change, update the [Installation section](#installation) in [README.md](README.md). 558 | - Update [CHANGELOG.md](CHANGELOG.md) following the format on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). 559 | 3. Commit changes: 560 | - Commit the changes with a message like: `chore: bump version to x.y.z.` 561 | 4. Release the gem: 562 | - On the `main` branch, run `bundle exec rake release` to create a git tag for the new version, push commits and tags to origin, and publish `.gem` file to [rubygems.org](https://rubygems.org). 563 | 564 | ## Contributing 565 | 566 | Bug reports and pull requests are welcome on GitHub at https://github.com/unleash/unleash-client-ruby. 567 | 568 | Be sure to run both `bundle exec rspec` and `bundle exec rubocop` in your branch before creating a pull request. 569 | 570 | Please include tests with any pull requests, to avoid regressions. 571 | 572 | Check out our guide for more information on how to build and scale [feature flag systems](https://docs.getunleash.io/topics/feature-flags/feature-flag-best-practices). 573 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task default: :spec 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "unleash" 5 | require "unleash/client" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /bin/unleash-client: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'optparse' 4 | require 'unleash' 5 | require 'unleash/client' 6 | require 'unleash/context' 7 | 8 | options = { 9 | variant: false, 10 | verbose: false, 11 | quiet: false, 12 | url: 'http://localhost:4242', 13 | demo: false, 14 | disable_metrics: true, 15 | custom_http_headers: {}, 16 | sleep: 0.1 17 | } 18 | 19 | OptionParser.new do |opts| 20 | opts.banner = "Usage: #{__FILE__} [options] feature [contextKey1=val1] [contextKey2=val2] \n\n" \ 21 | "Where contextKey1 could be user_id, session_id, remote_address or any field in the Context class (or any property within it).\n" 22 | 23 | opts.on("-V", "--variant", "Fetch variant for feature") do |v| 24 | options[:variant] = v 25 | end 26 | 27 | opts.on("-v", "--[no-]verbose", "Run verbosely") do |v| 28 | options[:verbose] = v 29 | end 30 | 31 | opts.on("-q", "--quiet", "Quiet mode, minimum output only") do |v| 32 | options[:quiet] = v 33 | end 34 | 35 | opts.on("-uURL", "--url=URL", "URL base for the Unleash feature toggle service") do |u| 36 | options[:url] = u 37 | end 38 | 39 | opts.on("-d", "--demo", "Demo load by looping, instead of a simple lookup") do |d| 40 | options[:demo] = d 41 | end 42 | 43 | opts.on("-m", "--[no-]metrics", "Enable metrics reporting") do |m| 44 | options[:disable_metrics] = !m 45 | end 46 | 47 | opts.on("-sSLEEP", "--sleep=SLEEP", Float, "Sleep interval between checks (seconds) in demo") do |s| 48 | options[:sleep] = s 49 | end 50 | 51 | opts.on("-H", "--http-headers='Authorization: *:developement.secretstring'", 52 | "Adds http headers to all requests on the unleash server. Use multiple times for multiple headers.") do |h| 53 | http_header_as_hash = [h].to_h{ |l| l.split(": ") }.transform_keys(&:to_sym) 54 | 55 | options[:custom_http_headers].merge!(http_header_as_hash) 56 | end 57 | 58 | opts.on("-h", "--help", "Prints this help") do 59 | puts opts 60 | exit 61 | end 62 | end.parse! 63 | 64 | feature_name = ARGV.shift 65 | raise 'feature_name is required. see --help for usage.' unless feature_name 66 | 67 | options[:verbose] = false if options[:quiet] 68 | 69 | log_level = \ 70 | if options[:quiet] 71 | Logger::ERROR 72 | elsif options[:verbose] 73 | Logger::DEBUG 74 | else 75 | Logger::WARN 76 | end 77 | 78 | @unleash = Unleash::Client.new( 79 | url: options[:url], 80 | app_name: 'unleash-client-ruby-cli', 81 | disable_metrics: options[:metrics], 82 | custom_http_headers: options[:custom_http_headers], 83 | log_level: log_level 84 | ) 85 | 86 | context_params = ARGV.to_h{ |l| l.split("=") }.transform_keys(&:to_sym) 87 | context_properties = context_params.reject{ |k, _v| [:user_id, :session_id, :remote_address].include? k } 88 | context_params.select!{ |k, _v| [:user_id, :session_id, :remote_address].include? k } 89 | context_params.merge!(properties: context_properties) unless context_properties.nil? 90 | unleash_context = Unleash::Context.new(context_params) 91 | 92 | if options[:verbose] 93 | puts "Running configuration:" 94 | p options 95 | puts "feature: #{feature_name}" 96 | puts "context_args: #{ARGV}" 97 | puts "context_params: #{context_params}" 98 | puts "context: #{unleash_context}" 99 | puts "" 100 | end 101 | 102 | if options[:demo] 103 | loop do 104 | enabled = @unleash.is_enabled?(feature_name, unleash_context) 105 | print enabled ? '.' : '|' 106 | sleep options[:sleep] 107 | end 108 | elsif options[:variant] 109 | variant = @unleash.get_variant(feature_name, unleash_context) 110 | puts " For feature '#{feature_name}' got variant '#{variant}'" 111 | else 112 | if @unleash.is_enabled?(feature_name, unleash_context) 113 | puts " '#{feature_name}' is enabled according to unleash" 114 | else 115 | puts " '#{feature_name}' is disabled according to unleash" 116 | end 117 | end 118 | 119 | @unleash.shutdown 120 | -------------------------------------------------------------------------------- /echo_client_spec_version.rb: -------------------------------------------------------------------------------- 1 | require_relative 'lib/unleash/spec_version' 2 | 3 | puts Unleash::CLIENT_SPECIFICATION_VERSION 4 | -------------------------------------------------------------------------------- /examples/bootstrap.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'unleash' 4 | require 'unleash/context' 5 | require 'unleash/bootstrap/configuration' 6 | 7 | puts ">> START bootstrap.rb" 8 | 9 | @unleash = Unleash::Client.new( 10 | url: 'https://unleash.herokuapp.com/api', 11 | custom_http_headers: { 'Authorization': '943ca9171e2c884c545c5d82417a655fb77cec970cc3b78a8ff87f4406b495d0' }, 12 | app_name: 'bootstrap-test', 13 | instance_id: 'local-test-cli', 14 | refresh_interval: 2, 15 | disable_client: true, 16 | disable_metrics: true, 17 | metrics_interval: 2, 18 | retry_limit: 2, 19 | bootstrap_config: Unleash::Bootstrap::Configuration.new(file_path: "examples/default-toggles.json") 20 | ) 21 | 22 | feature_name = "featureX" 23 | unleash_context = Unleash::Context.new 24 | unleash_context.user_id = 123 25 | 26 | sleep 1 27 | 3.times do 28 | if @unleash.is_enabled?(feature_name, unleash_context) 29 | puts "> #{feature_name} is enabled" 30 | else 31 | puts "> #{feature_name} is not enabled" 32 | end 33 | sleep 1 34 | puts "---" 35 | puts "" 36 | puts "" 37 | end 38 | 39 | sleep 3 40 | feature_name = "foobar" 41 | if @unleash.is_enabled?(feature_name, unleash_context, true) 42 | puts "> #{feature_name} is enabled" 43 | else 44 | puts "> #{feature_name} is not enabled" 45 | end 46 | 47 | puts "> shutting down client..." 48 | 49 | @unleash.shutdown 50 | 51 | puts ">> END bootstrap.rb" 52 | -------------------------------------------------------------------------------- /examples/default-toggles.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "features": [ 4 | { 5 | "name": "featureX", 6 | "enabled": true, 7 | "strategies": [ 8 | { 9 | "name": "default" 10 | } 11 | ] 12 | }, 13 | { 14 | "name": "featureY", 15 | "enabled": false, 16 | "strategies": [ 17 | { 18 | "name": "baz", 19 | "parameters": { 20 | "foo": "bar" 21 | } 22 | } 23 | ] 24 | }, 25 | { 26 | "name": "featureZ", 27 | "enabled": true, 28 | "strategies": [ 29 | { 30 | "name": "default" 31 | }, 32 | { 33 | "name": "hola", 34 | "parameters": { 35 | "name": "val" 36 | } 37 | } 38 | ] 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /examples/extending_unleash_with_opentelemetry.rb: -------------------------------------------------------------------------------- 1 | # example on how to extend the unleash client with opentelemetry by monkey patching it. 2 | # to be added before initializing the client. 3 | # in rails it could be added, for example, at: 4 | # config/initializers/unleash.rb 5 | 6 | require 'opentelemetry-api' 7 | require 'unleash' 8 | 9 | module UnleashExtensions 10 | module OpenTelemetry 11 | TRACER = ::OpenTelemetry.tracer_provider.tracer('Unleash-Client', Unleash::VERSION) 12 | 13 | module Client 14 | def initialize(*opts) 15 | UnleashExtensions::OpenTelemetry::TRACER.in_span("#{self.class.name}##{__method__}") do |_span| 16 | super(*opts) 17 | end 18 | end 19 | 20 | def is_enabled?(feature, *args) 21 | UnleashExtensions::OpenTelemetry::TRACER.in_span("#{self.class.name}##{__method__}") do |span| 22 | result = super(feature, *args) 23 | 24 | # OpenTelemetry::SemanticConventions::Trace::FEATURE_FLAG_* is not in the `opentelemetry-semantic_conventions` gem yet 25 | span.add_attributes({ 26 | 'feature_flag.provider_name' => 'Unleash', 27 | 'feature_flag.key' => feature, 28 | 'feature_flag.variant' => result 29 | }) 30 | 31 | result 32 | end 33 | end 34 | end 35 | end 36 | 37 | module MetricsReporter 38 | def post 39 | UnleashExtensions::OpenTelemetry::TRACER.in_span("#{self.class.name}##{__method__}") do |_span| 40 | super 41 | end 42 | end 43 | end 44 | 45 | module ToggleFetcher 46 | def fetch 47 | UnleashExtensions::OpenTelemetry::TRACER.in_span("#{self.class.name}##{__method__}") do |_span| 48 | super 49 | end 50 | end 51 | 52 | def save! 53 | UnleashExtensions::OpenTelemetry::TRACER.in_span("#{self.class.name}##{__method__}") do |_span| 54 | super 55 | end 56 | end 57 | end 58 | end 59 | 60 | # MonkeyPatch here: 61 | ::Unleash::Client.prepend UnleashExtensions::OpenTelemetry::Client 62 | ::Unleash::MetricsReporter.prepend UnleashExtensions::OpenTelemetry::MetricsReporter 63 | ::Unleash::ToggleFetcher.prepend UnleashExtensions::OpenTelemetry::ToggleFetcher 64 | -------------------------------------------------------------------------------- /examples/simple.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'unleash' 4 | require 'unleash/context' 5 | 6 | puts ">> START simple.rb" 7 | 8 | # Unleash.configure do |config| 9 | # config.url = 'https://unleash.herokuapp.com/api' 10 | # config.custom_http_headers = { 'Authorization': '943ca9171e2c884c545c5d82417a655fb77cec970cc3b78a8ff87f4406b495d0' } 11 | # config.app_name = 'simple-test' 12 | # config.refresh_interval = 2 13 | # config.metrics_interval = 2 14 | # config.retry_limit = 2 15 | # end 16 | # @unleash = Unleash::Client.new 17 | 18 | # or: 19 | 20 | @unleash = Unleash::Client.new( 21 | url: 'https://unleash.herokuapp.com/api', 22 | custom_http_headers: { 'Authorization': '943ca9171e2c884c545c5d82417a655fb77cec970cc3b78a8ff87f4406b495d0' }, 23 | app_name: 'simple-test', 24 | instance_id: 'local-test-cli', 25 | refresh_interval: 2, 26 | metrics_interval: 2, 27 | retry_limit: 2 28 | ) 29 | 30 | # feature_name = "AwesomeFeature" 31 | feature_name = "4343443" 32 | unleash_context = Unleash::Context.new 33 | unleash_context.user_id = 123 34 | 35 | sleep 1 36 | 3.times do 37 | if @unleash.is_enabled?(feature_name, unleash_context) 38 | puts "> #{feature_name} is enabled" 39 | else 40 | puts "> #{feature_name} is not enabled" 41 | end 42 | sleep 1 43 | puts "---" 44 | puts "" 45 | puts "" 46 | end 47 | 48 | sleep 3 49 | feature_name = "foobar" 50 | if @unleash.is_enabled?(feature_name, unleash_context, true) 51 | puts "> #{feature_name} is enabled" 52 | else 53 | puts "> #{feature_name} is not enabled" 54 | end 55 | 56 | puts "> shutting down client..." 57 | 58 | @unleash.shutdown 59 | 60 | puts ">> END simple.rb" 61 | -------------------------------------------------------------------------------- /lib/unleash.rb: -------------------------------------------------------------------------------- 1 | require 'unleash/version' 2 | require 'unleash/spec_version' 3 | require 'unleash/configuration' 4 | require 'unleash/strategies' 5 | require 'unleash/context' 6 | require 'unleash/client' 7 | require 'logger' 8 | 9 | module Unleash 10 | TIME_RESOLUTION = 3 11 | 12 | class << self 13 | attr_accessor :configuration, :toggle_fetcher, :reporter, :logger, :engine 14 | end 15 | 16 | self.configuration = Unleash::Configuration.new 17 | 18 | # Deprecated: Use Unleash.configure to add custom strategies 19 | STRATEGIES = self.configuration.strategies 20 | 21 | # Support for configuration via yield: 22 | def self.configure 23 | yield(configuration) 24 | 25 | self.configuration.validate! 26 | self.configuration.refresh_backup_file! 27 | end 28 | 29 | def self.strategies 30 | self.configuration.strategies 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/unleash/bootstrap/configuration.rb: -------------------------------------------------------------------------------- 1 | module Unleash 2 | module Bootstrap 3 | class Configuration 4 | attr_accessor :data, :file_path, :url, :url_headers, :block 5 | 6 | def initialize(opts = {}) 7 | self.file_path = resolve_value_indifferently(opts, 'file_path') || ENV['UNLEASH_BOOTSTRAP_FILE'] || nil 8 | self.url = resolve_value_indifferently(opts, 'url') || ENV['UNLEASH_BOOTSTRAP_URL'] || nil 9 | self.url_headers = resolve_value_indifferently(opts, 'url_headers') 10 | self.data = resolve_value_indifferently(opts, 'data') 11 | self.block = resolve_value_indifferently(opts, 'block') 12 | end 13 | 14 | def valid? 15 | ![self.data, self.file_path, self.url, self.block].all?(&:nil?) 16 | end 17 | 18 | private 19 | 20 | def resolve_value_indifferently(opts, key) 21 | opts[key] || opts[key.to_sym] 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/unleash/bootstrap/handler.rb: -------------------------------------------------------------------------------- 1 | require 'unleash/bootstrap/provider/from_url' 2 | require 'unleash/bootstrap/provider/from_file' 3 | 4 | module Unleash 5 | module Bootstrap 6 | class Handler 7 | attr_accessor :configuration 8 | 9 | def initialize(configuration) 10 | self.configuration = configuration 11 | end 12 | 13 | # @return [String] JSON string representing data returned from an Unleash server 14 | def retrieve_toggles 15 | return configuration.data unless self.configuration.data.nil? 16 | return configuration.block.call if self.configuration.block.is_a?(Proc) 17 | return Provider::FromFile.read(configuration.file_path) unless self.configuration.file_path.nil? 18 | 19 | Provider::FromUrl.read(configuration.url, configuration.url_headers) unless self.configuration.url.nil? 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/unleash/bootstrap/provider/base.rb: -------------------------------------------------------------------------------- 1 | module Unleash 2 | module Bootstrap 3 | module Provider 4 | class NotImplemented < RuntimeError 5 | end 6 | 7 | class Base 8 | def read 9 | raise NotImplemented, "Bootstrap is not implemented" 10 | end 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/unleash/bootstrap/provider/from_file.rb: -------------------------------------------------------------------------------- 1 | require 'unleash/bootstrap/provider/base' 2 | 3 | module Unleash 4 | module Bootstrap 5 | module Provider 6 | class FromFile < Base 7 | # @param file_path [String] 8 | def self.read(file_path) 9 | File.read(file_path) 10 | end 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/unleash/bootstrap/provider/from_url.rb: -------------------------------------------------------------------------------- 1 | require 'unleash/bootstrap/provider/base' 2 | 3 | module Unleash 4 | module Bootstrap 5 | module Provider 6 | class FromUrl < Base 7 | # @param url [String] 8 | # @param headers [Hash, nil] HTTP headers to use. If not set, the unleash client SDK ones will be used. 9 | def self.read(url, headers = nil) 10 | response = Unleash::Util::Http.get(URI.parse(url), nil, headers) 11 | 12 | return nil if response.code != '200' 13 | 14 | response.body 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/unleash/client.rb: -------------------------------------------------------------------------------- 1 | require 'unleash/configuration' 2 | require 'unleash/toggle_fetcher' 3 | require 'unleash/metrics_reporter' 4 | require 'unleash/scheduled_executor' 5 | require 'unleash/variant' 6 | require 'unleash/util/http' 7 | require 'logger' 8 | require 'time' 9 | 10 | module Unleash 11 | class Client 12 | attr_accessor :fetcher_scheduled_executor, :metrics_scheduled_executor 13 | 14 | # rubocop:disable Metrics/AbcSize 15 | def initialize(*opts) 16 | Unleash.configuration = Unleash::Configuration.new(*opts) unless opts.empty? 17 | Unleash.configuration.validate! 18 | 19 | Unleash.logger = Unleash.configuration.logger.clone 20 | Unleash.logger.level = Unleash.configuration.log_level 21 | Unleash.engine = YggdrasilEngine.new 22 | Unleash.engine.register_custom_strategies(Unleash.configuration.strategies.custom_strategies) 23 | 24 | Unleash.toggle_fetcher = Unleash::ToggleFetcher.new Unleash.engine 25 | if Unleash.configuration.disable_client 26 | Unleash.logger.warn "Unleash::Client is disabled! Will only return default (or bootstrapped if available) results!" 27 | Unleash.logger.warn "Unleash::Client is disabled! Metrics and MetricsReporter are also disabled!" 28 | Unleash.configuration.disable_metrics = true 29 | return 30 | end 31 | 32 | register 33 | start_toggle_fetcher 34 | start_metrics unless Unleash.configuration.disable_metrics 35 | end 36 | # rubocop:enable Metrics/AbcSize 37 | 38 | def is_enabled?(feature, context = nil, default_value_param = false, &fallback_blk) 39 | Unleash.logger.debug "Unleash::Client.is_enabled? feature: #{feature} with context #{context}" 40 | 41 | default_value = if block_given? 42 | default_value_param || !!fallback_blk.call(feature, context) 43 | else 44 | default_value_param 45 | end 46 | 47 | toggle_enabled = Unleash.engine.enabled?(feature, context) 48 | if toggle_enabled.nil? 49 | Unleash.logger.debug "Unleash::Client.is_enabled? feature: #{feature} not found" 50 | Unleash.engine.count_toggle(feature, false) 51 | return default_value 52 | end 53 | 54 | Unleash.engine.count_toggle(feature, toggle_enabled) 55 | 56 | toggle_enabled 57 | end 58 | 59 | def is_disabled?(feature, context = nil, default_value_param = true, &fallback_blk) 60 | !is_enabled?(feature, context, !default_value_param, &fallback_blk) 61 | end 62 | 63 | # enabled? is a more ruby idiomatic method name than is_enabled? 64 | alias enabled? is_enabled? 65 | # disabled? is a more ruby idiomatic method name than is_disabled? 66 | alias disabled? is_disabled? 67 | 68 | # execute a code block (passed as a parameter), if is_enabled? is true. 69 | def if_enabled(feature, context = nil, default_value = false, &blk) 70 | yield(blk) if is_enabled?(feature, context, default_value) 71 | end 72 | 73 | # execute a code block (passed as a parameter), if is_disabled? is true. 74 | def if_disabled(feature, context = nil, default_value = true, &blk) 75 | yield(blk) if is_disabled?(feature, context, default_value) 76 | end 77 | 78 | def get_variant(feature, context = Unleash::Context.new, fallback_variant = disabled_variant) 79 | variant = Unleash.engine.get_variant(feature, context) 80 | 81 | if variant.nil? 82 | Unleash.logger.debug "Unleash::Client.get_variant variants for feature: #{feature} not found" 83 | Unleash.engine.count_toggle(feature, false) 84 | return fallback_variant 85 | end 86 | 87 | variant = Variant.new(variant) 88 | 89 | Unleash.engine.count_variant(feature, variant.name) 90 | Unleash.engine.count_toggle(feature, variant.feature_enabled) 91 | 92 | # TODO: Add to README: name, payload, enabled (bool) 93 | 94 | variant 95 | end 96 | 97 | # safe shutdown: also flush metrics to server and toggles to disk 98 | def shutdown 99 | unless Unleash.configuration.disable_client 100 | Unleash.reporter.post unless Unleash.configuration.disable_metrics 101 | shutdown! 102 | end 103 | end 104 | 105 | # quick shutdown: just kill running threads 106 | def shutdown! 107 | unless Unleash.configuration.disable_client 108 | self.fetcher_scheduled_executor.exit 109 | self.metrics_scheduled_executor.exit unless Unleash.configuration.disable_metrics 110 | end 111 | end 112 | 113 | private 114 | 115 | def info 116 | { 117 | 'appName': Unleash.configuration.app_name, 118 | 'instanceId': Unleash.configuration.instance_id, 119 | 'connectionId': Unleash.configuration.connection_id, 120 | 'sdkVersion': "unleash-client-ruby:" + Unleash::VERSION, 121 | 'strategies': Unleash.strategies.known_strategies, 122 | 'started': Time.now.iso8601(Unleash::TIME_RESOLUTION), 123 | 'interval': Unleash.configuration.metrics_interval_in_millis, 124 | 'platformName': RUBY_ENGINE, 125 | 'platformVersion': RUBY_VERSION, 126 | 'yggdrasilVersion': "0.13.3", 127 | 'specVersion': Unleash::CLIENT_SPECIFICATION_VERSION 128 | } 129 | end 130 | 131 | def start_toggle_fetcher 132 | self.fetcher_scheduled_executor = Unleash::ScheduledExecutor.new( 133 | 'ToggleFetcher', 134 | Unleash.configuration.refresh_interval, 135 | Unleash.configuration.retry_limit, 136 | first_fetch_is_eager 137 | ) 138 | self.fetcher_scheduled_executor.run do 139 | Unleash.toggle_fetcher.fetch 140 | end 141 | end 142 | 143 | def start_metrics 144 | Unleash.reporter = Unleash::MetricsReporter.new 145 | self.metrics_scheduled_executor = Unleash::ScheduledExecutor.new( 146 | 'MetricsReporter', 147 | Unleash.configuration.metrics_interval, 148 | Unleash.configuration.retry_limit 149 | ) 150 | self.metrics_scheduled_executor.run do 151 | Unleash.reporter.post 152 | end 153 | end 154 | 155 | def register 156 | Unleash.logger.debug "register()" 157 | 158 | # Send the request, if possible 159 | begin 160 | response = Unleash::Util::Http.post(Unleash.configuration.client_register_uri, info.to_json) 161 | rescue StandardError => e 162 | Unleash.logger.error "unable to register client with unleash server due to exception #{e.class}:'#{e}'." 163 | Unleash.logger.error "stacktrace: #{e.backtrace}" 164 | end 165 | Unleash.logger.debug "client registered: #{response}" 166 | end 167 | 168 | def disabled_variant 169 | @disabled_variant ||= Unleash::Variant.disabled_variant 170 | end 171 | 172 | def first_fetch_is_eager 173 | Unleash.configuration.use_bootstrap? 174 | end 175 | end 176 | end 177 | -------------------------------------------------------------------------------- /lib/unleash/configuration.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | require 'tmpdir' 3 | require 'unleash/bootstrap/configuration' 4 | 5 | module Unleash 6 | class Configuration 7 | attr_accessor \ 8 | :url, 9 | :app_name, 10 | :environment, 11 | :instance_id, 12 | :project_name, 13 | :custom_http_headers, 14 | :disable_client, 15 | :disable_metrics, 16 | :timeout, 17 | :retry_limit, 18 | :refresh_interval, 19 | :metrics_interval, 20 | :backup_file, 21 | :logger, 22 | :log_level, 23 | :bootstrap_config, 24 | :strategies, 25 | :use_delta_api 26 | attr_reader :connection_id 27 | 28 | def initialize(opts = {}) 29 | validate_custom_http_headers!(opts[:custom_http_headers]) if opts.has_key?(:custom_http_headers) 30 | set_defaults 31 | 32 | initialize_default_logger if opts[:logger].nil? 33 | 34 | merge(opts) 35 | refresh_backup_file! 36 | end 37 | 38 | def metrics_interval_in_millis 39 | self.metrics_interval * 1_000 40 | end 41 | 42 | def validate! 43 | return if self.disable_client 44 | 45 | raise ArgumentError, "app_name is a required parameter." if self.app_name.nil? 46 | 47 | validate_custom_http_headers!(self.custom_http_headers) unless self.url.nil? 48 | end 49 | 50 | def refresh_backup_file! 51 | self.backup_file = File.join(Dir.tmpdir, "unleash-#{app_name}-repo.json") 52 | end 53 | 54 | def http_headers 55 | headers = { 56 | 'User-Agent' => "UnleashClientRuby/#{Unleash::VERSION} #{RUBY_ENGINE}/#{RUBY_VERSION} [#{RUBY_PLATFORM}]", 57 | 'UNLEASH-INSTANCEID' => self.instance_id, 58 | 'UNLEASH-APPNAME' => self.app_name, 59 | 'Unleash-Client-Spec' => CLIENT_SPECIFICATION_VERSION, 60 | 'UNLEASH-SDK' => "unleash-client-ruby:#{Unleash::VERSION}" 61 | }.merge!(generate_custom_http_headers) 62 | headers['UNLEASH-CONNECTION-ID'] = @connection_id 63 | headers 64 | end 65 | 66 | def fetch_toggles_uri 67 | uri = nil 68 | ## Personal feeling but Rubocop's suggestion here is too dense to be properly readable 69 | # rubocop:disable Style/ConditionalAssignment 70 | if self.use_delta_api 71 | uri = URI("#{self.url_stripped_of_slash}/client/delta") 72 | else 73 | uri = URI("#{self.url_stripped_of_slash}/client/features") 74 | end 75 | # rubocop:enable Style/ConditionalAssignment 76 | uri.query = "project=#{self.project_name}" unless self.project_name.nil? 77 | uri 78 | end 79 | 80 | def client_metrics_uri 81 | URI("#{self.url_stripped_of_slash}/client/metrics") 82 | end 83 | 84 | def client_register_uri 85 | URI("#{self.url_stripped_of_slash}/client/register") 86 | end 87 | 88 | def url_stripped_of_slash 89 | self.url.delete_suffix '/' 90 | end 91 | 92 | def use_bootstrap? 93 | self.bootstrap_config&.valid? 94 | end 95 | 96 | private 97 | 98 | def set_defaults 99 | self.app_name = nil 100 | self.environment = 'default' 101 | self.url = nil 102 | self.instance_id = SecureRandom.uuid 103 | self.project_name = nil 104 | self.disable_client = false 105 | self.disable_metrics = false 106 | self.refresh_interval = 15 107 | self.metrics_interval = 60 108 | self.timeout = 30 109 | self.retry_limit = Float::INFINITY 110 | self.backup_file = nil 111 | self.log_level = Logger::WARN 112 | self.bootstrap_config = nil 113 | self.strategies = Unleash::Strategies.new 114 | self.use_delta_api = false 115 | 116 | self.custom_http_headers = {} 117 | @connection_id = SecureRandom.uuid 118 | end 119 | 120 | def initialize_default_logger 121 | self.logger = Logger.new($stdout) 122 | 123 | # on default logger, use custom formatter that includes thread_name: 124 | self.logger.formatter = proc do |severity, datetime, _progname, msg| 125 | thread_name = (Thread.current[:name] || "Unleash").rjust(16, ' ') 126 | "[#{datetime.iso8601(6)} #{thread_name} #{severity.ljust(5, ' ')}] : #{msg}\n" 127 | end 128 | end 129 | 130 | def merge(opts) 131 | opts.each_pair{ |opt, val| set_option(opt, val) } 132 | self 133 | end 134 | 135 | def validate_custom_http_headers!(custom_http_headers) 136 | return if custom_http_headers.is_a?(Hash) || custom_http_headers.respond_to?(:call) 137 | 138 | raise ArgumentError, "custom_http_headers must be a Hash or a Proc." 139 | end 140 | 141 | def generate_custom_http_headers 142 | return self.custom_http_headers.call if self.custom_http_headers.respond_to?(:call) 143 | 144 | self.custom_http_headers 145 | end 146 | 147 | def set_option(opt, val) 148 | __send__("#{opt}=", val) 149 | rescue NoMethodError 150 | raise ArgumentError, "unknown configuration parameter '#{val}'" 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /lib/unleash/context.rb: -------------------------------------------------------------------------------- 1 | module Unleash 2 | class Context 3 | ATTRS = [:app_name, :environment, :user_id, :session_id, :remote_address, :current_time].freeze 4 | 5 | attr_accessor(*[ATTRS, :properties].flatten) 6 | 7 | def initialize(params = {}) 8 | raise ArgumentError, "Unleash::Context must be initialized with a hash." unless params.is_a?(Hash) 9 | 10 | self.app_name = value_for("appName", params, Unleash.configuration&.app_name) 11 | self.environment = value_for("environment", params, Unleash.configuration&.environment || "default") 12 | self.user_id = value_for("userId", params)&.to_s 13 | self.session_id = value_for("sessionId", params) 14 | self.remote_address = value_for("remoteAddress", params) 15 | self.current_time = value_for("currentTime", params, Time.now.utc.iso8601.to_s) 16 | 17 | properties = value_for("properties", params) 18 | self.properties = properties.is_a?(Hash) ? properties.transform_keys(&:to_sym) : {} 19 | end 20 | 21 | def to_s 22 | "" 24 | end 25 | 26 | def as_json(*_options) 27 | { 28 | appName: to_safe_value(self.app_name), 29 | environment: to_safe_value(self.environment), 30 | userId: to_safe_value(self.user_id), 31 | sessionId: to_safe_value(self.session_id), 32 | remoteAddress: to_safe_value(self.remote_address), 33 | currentTime: to_safe_value(self.current_time), 34 | properties: self.properties.transform_values{ |value| to_safe_value(value) } 35 | } 36 | end 37 | 38 | def to_json(*options) 39 | as_json(*options).to_json(*options) 40 | end 41 | 42 | def to_h 43 | ATTRS.map{ |attr| [attr, self.send(attr)] }.to_h.merge(properties: @properties) 44 | end 45 | 46 | # returns the value found for the key in the context, or raises a KeyError exception if not found. 47 | def get_by_name(name) 48 | normalized_name = underscore(name).to_sym 49 | 50 | if ATTRS.include? normalized_name 51 | self.send(normalized_name) 52 | else 53 | self.properties.fetch(normalized_name, nil) || self.properties.fetch(name.to_sym) 54 | end 55 | end 56 | 57 | def include?(name) 58 | normalized_name = underscore(name) 59 | return self.instance_variable_defined? "@#{normalized_name}" if ATTRS.include? normalized_name.to_sym 60 | 61 | self.properties.include?(normalized_name.to_sym) || self.properties.include?(name.to_sym) 62 | end 63 | 64 | private 65 | 66 | # Method to fetch values from hash for two types of keys: string in camelCase and symbol in snake_case 67 | def value_for(key, params, default_value = nil) 68 | params.values_at(key, key.to_sym, underscore(key), underscore(key).to_sym).compact.first || default_value 69 | end 70 | 71 | def to_safe_value(value) 72 | return nil if value.nil? 73 | 74 | if value.is_a?(Time) 75 | value.utc.iso8601 76 | else 77 | value.to_s 78 | end 79 | end 80 | 81 | # converts CamelCase to snake_case 82 | def underscore(camel_cased_word) 83 | camel_cased_word.to_s.gsub(/(.)([A-Z])/, '\1_\2').downcase 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/unleash/metrics_reporter.rb: -------------------------------------------------------------------------------- 1 | require 'unleash/configuration' 2 | require 'net/http' 3 | require 'json' 4 | require 'time' 5 | 6 | module Unleash 7 | class MetricsReporter 8 | LONGEST_WITHOUT_A_REPORT = 600 9 | 10 | attr_accessor :last_time 11 | 12 | def initialize 13 | self.last_time = Time.now 14 | end 15 | 16 | def generate_report 17 | metrics = Unleash.engine&.get_metrics 18 | return nil if metrics.nil? 19 | 20 | generate_report_from_bucket metrics 21 | end 22 | 23 | def post 24 | Unleash.logger.debug "post() Report" 25 | 26 | report = build_report 27 | return unless report 28 | 29 | send_report(report) 30 | end 31 | 32 | private 33 | 34 | def generate_report_from_bucket(bucket) 35 | { 36 | 'platformName': RUBY_ENGINE, 37 | 'platformVersion': RUBY_VERSION, 38 | 'yggdrasilVersion': "0.13.3", 39 | 'specVersion': Unleash::CLIENT_SPECIFICATION_VERSION, 40 | 'appName': Unleash.configuration.app_name, 41 | 'instanceId': Unleash.configuration.instance_id, 42 | 'connectionId': Unleash.configuration.connection_id, 43 | 'bucket': bucket 44 | } 45 | end 46 | 47 | def build_report 48 | report = generate_report 49 | return nil if report.nil? && Time.now - self.last_time < LONGEST_WITHOUT_A_REPORT 50 | 51 | report || generate_report_from_bucket({ 52 | 'start': self.last_time.utc.iso8601, 53 | 'stop': Time.now.utc.iso8601, 54 | 'toggles': {} 55 | }) 56 | end 57 | 58 | def send_report(report) 59 | self.last_time = Time.now 60 | headers = (Unleash.configuration.http_headers || {}).dup 61 | headers.merge!({ 'UNLEASH-INTERVAL' => Unleash.configuration.metrics_interval.to_s }) 62 | response = Unleash::Util::Http.post(Unleash.configuration.client_metrics_uri, report.to_json, headers) 63 | 64 | if ['200', '202'].include? response.code 65 | Unleash.logger.debug "Report sent to unleash server successfully. Server responded with http code #{response.code}" 66 | else 67 | # :nocov: 68 | Unleash.logger.error "Error when sending report to unleash server. Server responded with http code #{response.code}." 69 | # :nocov: 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/unleash/scheduled_executor.rb: -------------------------------------------------------------------------------- 1 | module Unleash 2 | class ScheduledExecutor 3 | attr_accessor :name, :interval, :max_exceptions, :retry_count, :thread, :immediate_execution 4 | 5 | def initialize(name, interval, max_exceptions = 5, immediate_execution = false) 6 | self.name = name || '' 7 | self.interval = interval 8 | self.max_exceptions = max_exceptions 9 | self.retry_count = 0 10 | self.thread = nil 11 | self.immediate_execution = immediate_execution 12 | end 13 | 14 | def run(&blk) 15 | self.thread = Thread.new do 16 | Thread.current[:name] = self.name 17 | 18 | run_blk{ blk.call } if self.immediate_execution 19 | 20 | Unleash.logger.debug "thread #{name} loop starting" 21 | loop do 22 | Unleash.logger.debug "thread #{name} sleeping for #{interval} seconds" 23 | sleep interval 24 | 25 | run_blk{ blk.call } 26 | 27 | if exceeded_max_exceptions? 28 | Unleash.logger.error "thread #{name} retry_count (#{self.retry_count}) exceeded " \ 29 | "max_exceptions (#{self.max_exceptions}). Stopping with retries." 30 | break 31 | end 32 | end 33 | Unleash.logger.debug "thread #{name} loop ended" 34 | end 35 | end 36 | 37 | def running? 38 | self.thread.is_a?(Thread) && self.thread.alive? 39 | end 40 | 41 | def exit 42 | if self.running? 43 | Unleash.logger.warn "thread #{name} will exit!" 44 | self.thread.exit 45 | self.thread.join if self.running? 46 | else 47 | Unleash.logger.info "thread #{name} was already stopped!" 48 | end 49 | end 50 | 51 | private 52 | 53 | def run_blk(&blk) 54 | Unleash.logger.debug "thread #{name} starting execution" 55 | 56 | yield(blk) 57 | self.retry_count = 0 58 | rescue StandardError => e 59 | self.retry_count += 1 60 | Unleash.logger.error "thread #{name} threw exception #{e.class} " \ 61 | " (#{self.retry_count}/#{self.max_exceptions}): '#{e}'" 62 | Unleash.logger.debug "stacktrace: #{e.backtrace}" 63 | end 64 | 65 | def exceeded_max_exceptions? 66 | self.retry_count > self.max_exceptions 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/unleash/spec_version.rb: -------------------------------------------------------------------------------- 1 | module Unleash 2 | CLIENT_SPECIFICATION_VERSION = "5.2.0".freeze 3 | end 4 | -------------------------------------------------------------------------------- /lib/unleash/strategies.rb: -------------------------------------------------------------------------------- 1 | module Unleash 2 | class DefaultOverrideError < RuntimeError 3 | end 4 | 5 | class Strategies 6 | attr_accessor :strategies 7 | 8 | def initialize 9 | @strategies = {} 10 | end 11 | 12 | def includes?(name) 13 | @strategies.has_key?(name.to_s) || DEFAULT_STRATEGIES.include?(name.to_s) 14 | end 15 | 16 | def add(strategy) 17 | raise DefaultOverrideError, "Cannot override a default strategy" if DEFAULT_STRATEGIES.include?(strategy.name) 18 | 19 | @strategies[strategy.name] = strategy 20 | end 21 | 22 | def custom_strategies 23 | @strategies.values 24 | end 25 | 26 | def known_strategies 27 | @strategies.keys.map{ |key| { name: key } } 28 | end 29 | 30 | DEFAULT_STRATEGIES = ['applicationHostname', 'default', 'flexibleRollout', 'gradualRolloutRandom', 'gradualRolloutSessionId', 31 | 'gradualRolloutUserId', 'remoteAddress', 'userWithId'].freeze 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/unleash/toggle_fetcher.rb: -------------------------------------------------------------------------------- 1 | require 'unleash/configuration' 2 | require 'unleash/bootstrap/handler' 3 | require 'net/http' 4 | require 'json' 5 | require 'yggdrasil_engine' 6 | 7 | module Unleash 8 | class ToggleFetcher 9 | attr_accessor :toggle_engine, :toggle_lock, :toggle_resource, :etag, :retry_count 10 | 11 | def initialize(engine) 12 | self.toggle_engine = engine 13 | self.etag = nil 14 | self.toggle_lock = Mutex.new 15 | self.toggle_resource = ConditionVariable.new 16 | self.retry_count = 0 17 | 18 | begin 19 | # if bootstrap configuration is available, initialize. An immediate API read is also triggered 20 | if Unleash.configuration.use_bootstrap? 21 | bootstrap 22 | else 23 | fetch 24 | end 25 | rescue StandardError => e 26 | # fail back to reading the backup file 27 | Unleash.logger.warn "ToggleFetcher was unable to fetch from the network, attempting to read from backup file." 28 | Unleash.logger.debug "Exception Caught: #{e}" 29 | read! 30 | end 31 | 32 | # once initialized, somewhere else you will want to start a loop with fetch() 33 | end 34 | 35 | # rename to refresh_from_server! ?? 36 | def fetch 37 | Unleash.logger.debug "fetch()" 38 | return if Unleash.configuration.disable_client 39 | 40 | headers = (Unleash.configuration.http_headers || {}).dup 41 | headers.merge!({ 'UNLEASH-INTERVAL' => Unleash.configuration.refresh_interval.to_s }) 42 | response = Unleash::Util::Http.get(Unleash.configuration.fetch_toggles_uri, etag, headers) 43 | 44 | if response.code == '304' 45 | Unleash.logger.debug "No changes according to the unleash server, nothing to do." 46 | return 47 | elsif response.code != '200' 48 | raise IOError, "Unleash server returned unexpected HTTP response code #{response.code}."\ 49 | " Only handle response codes 200 (indicates changes) or 304 (no changes)." 50 | end 51 | 52 | self.etag = response['ETag'] 53 | 54 | # always synchronize with the local cache when fetching: 55 | update_engine_state!(response.body) 56 | 57 | save! response.body 58 | end 59 | 60 | def save!(toggle_data) 61 | Unleash.logger.debug "Will save toggles to disk now" 62 | 63 | backup_file = Unleash.configuration.backup_file 64 | backup_file_tmp = "#{backup_file}.tmp" 65 | 66 | self.toggle_lock.synchronize do 67 | File.open(backup_file_tmp, "w") do |file| 68 | file.write(toggle_data) 69 | end 70 | File.rename(backup_file_tmp, backup_file) 71 | end 72 | rescue StandardError => e 73 | # This is not really the end of the world. Swallowing the exception. 74 | Unleash.logger.error "Unable to save backup file. Exception thrown #{e.class}:'#{e}'" 75 | Unleash.logger.error "stacktrace: #{e.backtrace}" 76 | end 77 | 78 | private 79 | 80 | def update_engine_state!(toggle_data) 81 | self.toggle_lock.synchronize do 82 | self.toggle_engine.take_state(toggle_data) 83 | end 84 | 85 | # notify all threads waiting for this resource to no longer wait 86 | self.toggle_resource.broadcast 87 | rescue StandardError => e 88 | Unleash.logger.error "Failed to hydrate state: #{e.backtrace}" 89 | end 90 | 91 | def read! 92 | Unleash.logger.debug "read!()" 93 | backup_file = Unleash.configuration.backup_file 94 | return nil unless File.exist?(backup_file) 95 | 96 | backup_data = File.read(backup_file) 97 | update_engine_state!(backup_data) 98 | rescue IOError => e 99 | # :nocov: 100 | Unleash.logger.error "Unable to read the backup_file: #{e}" 101 | # :nocov: 102 | rescue JSON::ParserError => e 103 | # :nocov: 104 | Unleash.logger.error "Unable to parse JSON from existing backup_file: #{e}" 105 | # :nocov: 106 | rescue StandardError => e 107 | # :nocov: 108 | Unleash.logger.error "Unable to extract valid data from backup_file. Exception thrown: #{e}" 109 | # :nocov: 110 | end 111 | 112 | def bootstrap 113 | bootstrap_payload = Unleash::Bootstrap::Handler.new(Unleash.configuration.bootstrap_config).retrieve_toggles 114 | update_engine_state! bootstrap_payload 115 | 116 | # reset Unleash.configuration.bootstrap_data to free up memory, as we will never use it again 117 | Unleash.configuration.bootstrap_config = nil 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/unleash/util/http.rb: -------------------------------------------------------------------------------- 1 | require 'net/http' 2 | require 'uri' 3 | 4 | module Unleash 5 | module Util 6 | module Http 7 | def self.get(uri, etag = nil, headers_override = nil) 8 | http = http_connection(uri) 9 | 10 | request = Net::HTTP::Get.new(uri.request_uri, http_headers(etag, headers_override)) 11 | 12 | http.request(request) 13 | end 14 | 15 | def self.post(uri, body, headers_override = nil) 16 | http = http_connection(uri) 17 | 18 | request = Net::HTTP::Post.new(uri.request_uri, http_headers(nil, headers_override)) 19 | request.body = body 20 | 21 | http.request(request) 22 | end 23 | 24 | def self.http_connection(uri) 25 | http = Net::HTTP.new(uri.host, uri.port) 26 | http.use_ssl = true if uri.scheme == 'https' 27 | http.response_body_encoding = 'UTF-8' if http.respond_to?(:response_body_encoding=) 28 | http.open_timeout = Unleash.configuration.timeout # in seconds 29 | http.read_timeout = Unleash.configuration.timeout # in seconds 30 | 31 | http 32 | end 33 | 34 | # @param etag [String, nil] 35 | # @param headers_override [Hash, nil] 36 | def self.http_headers(etag = nil, headers_override = nil) 37 | Unleash.logger.debug "ETag: #{etag}" unless etag.nil? 38 | 39 | headers = (Unleash.configuration.http_headers || {}).dup 40 | headers = headers_override if headers_override.is_a?(Hash) 41 | headers['Content-Type'] = 'application/json' 42 | headers['If-None-Match'] = etag unless etag.nil? 43 | 44 | headers 45 | end 46 | 47 | private_class_method :http_connection, :http_headers 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/unleash/variant.rb: -------------------------------------------------------------------------------- 1 | module Unleash 2 | class Variant 3 | attr_accessor :name, :enabled, :payload, :feature_enabled 4 | 5 | def initialize(params = {}) 6 | raise ArgumentError, "Variant initializer requires a hash." unless params.is_a?(Hash) 7 | 8 | self.name = params.values_at('name', :name).compact.first 9 | self.enabled = params.values_at('enabled', :enabled).compact.first || false 10 | self.payload = params.values_at('payload', :payload).compact.first 11 | self.feature_enabled = params.values_at('feature_enabled', :feature_enabled).compact.first || false 12 | 13 | raise ArgumentError, "Variant requires a name." if self.name.nil? 14 | end 15 | 16 | def to_s 17 | # :nocov: 18 | "" 19 | # :nocov: 20 | end 21 | 22 | def ==(other) 23 | self.name == other.name && self.enabled == other.enabled && self.payload == other.payload \ 24 | && self.feature_enabled == other.feature_enabled 25 | end 26 | 27 | def self.disabled_variant 28 | Variant.new(name: 'disabled', enabled: false, feature_enabled: false) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/unleash/version.rb: -------------------------------------------------------------------------------- 1 | module Unleash 2 | VERSION = "6.3.1".freeze 3 | end 4 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | require 'simplecov-lcov' 3 | 4 | SimpleCov::Formatter::LcovFormatter.config do |config| 5 | config.report_with_single_file = true 6 | config.single_report_path = 'coverage/lcov.info' 7 | end 8 | 9 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new( 10 | [ 11 | SimpleCov::Formatter::HTMLFormatter, 12 | SimpleCov::Formatter::LcovFormatter 13 | ] 14 | ) 15 | 16 | SimpleCov.start do 17 | add_filter '/spec/' 18 | end 19 | 20 | require "bundler/setup" 21 | require "unleash" 22 | require "unleash/client" 23 | 24 | require 'webmock/rspec' 25 | 26 | WebMock.disable_net_connect!(allow_localhost: false) 27 | 28 | RSpec.configure do |config| 29 | # Enable flags like --only-failures and --next-failure 30 | config.example_status_persistence_file_path = ".rspec_status" 31 | 32 | config.expect_with :rspec do |c| 33 | c.syntax = :expect 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/unleash/bootstrap-resources/features-v1.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "features": [ 4 | { 5 | "name": "featureX", 6 | "enabled": true, 7 | "strategies": [ 8 | { 9 | "name": "default" 10 | } 11 | ] 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /spec/unleash/bootstrap/handler_spec.rb: -------------------------------------------------------------------------------- 1 | require 'unleash/bootstrap/handler' 2 | require 'unleash/bootstrap/configuration' 3 | 4 | RSpec.describe Unleash::Bootstrap::Handler do 5 | Unleash.configure do |config| 6 | config.url = 'http://unleash-url/' 7 | config.app_name = 'my-test-app' 8 | config.instance_id = 'rspec/test' 9 | config.custom_http_headers = { 'X-API-KEY' => '123' } 10 | end 11 | 12 | it 'is marked as invalid when no bootstrap options are provided' do 13 | bootstrap_config = Unleash::Bootstrap::Configuration.new 14 | expect(bootstrap_config.valid?).to be(false) 15 | end 16 | 17 | it 'is marked as valid when at least one valid bootstrap option is provided' do 18 | bootstrap_config = Unleash::Bootstrap::Configuration.new({ 'data' => '' }) 19 | expect(bootstrap_config.valid?).to be(true) 20 | end 21 | 22 | it 'resolves bootstrap toggle correctly from url provider' do 23 | expected_repsonse_data = '{ 24 | "version": 1, 25 | "features": [ 26 | { 27 | "name": "featureX", 28 | "enabled": true, 29 | "strategies": [{ "name": "default" }] 30 | } 31 | ] 32 | }' 33 | 34 | WebMock.stub_request(:get, "http://test-url/") 35 | .with( 36 | headers: { 37 | 'Accept' => '*/*', 38 | 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 39 | 'Content-Type' => 'application/json', 40 | 'User-Agent' => 'Ruby' 41 | } 42 | ) 43 | .to_return(status: 200, body: expected_repsonse_data, headers: {}) 44 | 45 | url_provider_options = { 46 | 'url' => 'http://test-url/', 47 | 'url_headers' => {} 48 | } 49 | 50 | bootstrap_config = Unleash::Bootstrap::Configuration.new(url_provider_options) 51 | bootstrap_response = Unleash::Bootstrap::Handler.new(bootstrap_config).retrieve_toggles 52 | expect(JSON.parse(bootstrap_response)).to eq(JSON.parse(expected_repsonse_data)) 53 | end 54 | 55 | it 'resolves bootstrap toggle correctly from file provider' do 56 | file_path = './spec/unleash/bootstrap-resources/features-v1.json' 57 | actual_file_contents = File.open(file_path).read 58 | 59 | file_provider_options = { 60 | 'file_path' => file_path 61 | } 62 | 63 | bootstrap_config = Unleash::Bootstrap::Configuration.new(file_provider_options) 64 | bootstrap_response = Unleash::Bootstrap::Handler.new(bootstrap_config).retrieve_toggles 65 | 66 | expect(JSON.parse(bootstrap_response)).to eq(JSON.parse(actual_file_contents)) 67 | end 68 | 69 | it 'resolves bootstrap toggle correctly from raw data' do 70 | expected_repsonse_data = '{ 71 | "version": 1, 72 | "features": [ 73 | { 74 | "name": "featureX", 75 | "enabled": true, 76 | "strategies": [{ "name": "default" }] 77 | } 78 | ] 79 | }' 80 | 81 | data_provider_options = { 82 | 'data' => expected_repsonse_data 83 | } 84 | 85 | bootstrap_config = Unleash::Bootstrap::Configuration.new(data_provider_options) 86 | bootstrap_response = Unleash::Bootstrap::Handler.new(bootstrap_config).retrieve_toggles 87 | 88 | expect(JSON.parse(bootstrap_response)).to eq(JSON.parse(expected_repsonse_data)) 89 | end 90 | 91 | it 'resolves bootstrap toggle correctly from lambda' do 92 | expected_repsonse_data = '{ 93 | "version": 1, 94 | "features": [ 95 | { 96 | "name": "featureX", 97 | "enabled": true, 98 | "strategies": [{ "name": "default" }] 99 | } 100 | ] 101 | }' 102 | 103 | data_provider_options = { 104 | 'block' => -> { expected_repsonse_data } 105 | } 106 | 107 | bootstrap_config = Unleash::Bootstrap::Configuration.new(data_provider_options) 108 | bootstrap_response = Unleash::Bootstrap::Handler.new(bootstrap_config).retrieve_toggles 109 | 110 | expect(JSON.parse(bootstrap_response)).to eq(JSON.parse(expected_repsonse_data)) 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /spec/unleash/bootstrap/provider/from_file_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec/json_expectations' 2 | require 'unleash/bootstrap/provider/from_file' 3 | require 'json' 4 | 5 | RSpec.describe Unleash::Bootstrap::Provider::FromFile do 6 | before do 7 | Unleash.configuration = Unleash::Configuration.new 8 | Unleash.logger = Unleash.configuration.logger 9 | end 10 | 11 | it 'loads bootstrap toggle correctly from file' do 12 | bootstrap_file = './spec/unleash/bootstrap-resources/features-v1.json' 13 | 14 | bootstrap_contents = Unleash::Bootstrap::Provider::FromFile.read(bootstrap_file) 15 | bootstrap_features = JSON.parse(bootstrap_contents)['features'] 16 | 17 | file_contents = File.open(bootstrap_file).read 18 | file_features = JSON.parse(file_contents)['features'] 19 | 20 | expect(bootstrap_features).to include_json(file_features) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/unleash/bootstrap/provider/from_url_spec.rb: -------------------------------------------------------------------------------- 1 | require 'unleash/bootstrap/provider/from_url' 2 | require 'json' 3 | 4 | RSpec.describe Unleash::Bootstrap::Provider::FromUrl do 5 | it 'loads bootstrap toggle correctly from URL' do 6 | bootstrap_file = './spec/unleash/bootstrap-resources/features-v1.json' 7 | 8 | file_contents = File.open(bootstrap_file).read 9 | file_features = JSON.parse(file_contents)['features'] 10 | 11 | WebMock.stub_request(:get, "http://test-url/bootstrap-goodness") 12 | .with( 13 | headers: { 14 | 'Accept' => '*/*', 15 | 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 16 | 'Content-Type' => 'application/json', 17 | 'User-Agent' => 'Ruby' 18 | } 19 | ) 20 | .to_return(status: 200, body: file_contents, headers: {}) 21 | 22 | bootstrap_contents = Unleash::Bootstrap::Provider::FromUrl.read('http://test-url/bootstrap-goodness', {}) 23 | bootstrap_features = JSON.parse(bootstrap_contents)['features'] 24 | 25 | expect(bootstrap_features).to include_json(file_features) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/unleash/client_spec.rb: -------------------------------------------------------------------------------- 1 | require "securerandom" 2 | 3 | RSpec.describe Unleash::Client do 4 | after do 5 | WebMock.reset! 6 | File.delete(Unleash.configuration.backup_file) if File.exist?(Unleash.configuration.backup_file) 7 | end 8 | 9 | it "Uses custom http headers when initializing client" do 10 | fixed_uuid = "123e4567-e89b-12d3-a456-426614174000" 11 | allow(SecureRandom).to receive(:uuid).and_return(fixed_uuid) 12 | 13 | WebMock.stub_request(:post, "http://test-url/client/register") 14 | .with( 15 | headers: { 16 | 'Accept' => '*/*', 17 | 'Content-Type' => 'application/json', 18 | 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 19 | 'User-Agent' => "UnleashClientRuby/#{Unleash::VERSION} #{RUBY_ENGINE}/#{RUBY_VERSION} [#{RUBY_PLATFORM}]", 20 | 'Unleash-Sdk' => "unleash-client-ruby:#{Unleash::VERSION}", 21 | 'X-Api-Key' => '123' 22 | } 23 | ) 24 | .to_return(status: 200, body: "", headers: {}) 25 | WebMock.stub_request(:post, "http://test-url/client/metrics") 26 | .with( 27 | headers: { 28 | 'Accept' => '*/*', 29 | 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 30 | 'Content-Type' => 'application/json', 31 | 'User-Agent' => "UnleashClientRuby/#{Unleash::VERSION} #{RUBY_ENGINE}/#{RUBY_VERSION} [#{RUBY_PLATFORM}]", 32 | 'Unleash-Sdk' => "unleash-client-ruby:#{Unleash::VERSION}", 33 | 'Unleash-Interval' => '60' 34 | } 35 | ) 36 | .to_return(status: 200, body: "", headers: {}) 37 | 38 | simple_features = { 39 | "version": 1, 40 | "features": [ 41 | { 42 | "name": "Feature.A", 43 | "description": "Enabled toggle", 44 | "enabled": true, 45 | "strategies": [{ "name": "default" }] 46 | } 47 | ] 48 | } 49 | WebMock.stub_request(:get, "http://test-url/client/features") 50 | .with( 51 | headers: { 52 | 'Accept' => '*/*', 53 | 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 54 | 'Content-Type' => 'application/json', 55 | 'Unleash-Appname' => 'my-test-app', 56 | 'Unleash-Instanceid' => 'rspec/test', 57 | 'Unleash-Connection-Id' => fixed_uuid, 58 | 'User-Agent' => "UnleashClientRuby/#{Unleash::VERSION} #{RUBY_ENGINE}/#{RUBY_VERSION} [#{RUBY_PLATFORM}]", 59 | 'Unleash-Sdk' => "unleash-client-ruby:#{Unleash::VERSION}", 60 | 'X-Api-Key' => '123', 61 | 'Unleash-Interval' => '15' 62 | } 63 | ) 64 | .to_return(status: 200, body: simple_features.to_json, headers: {}) 65 | 66 | Unleash.configure do |config| 67 | config.url = 'http://test-url/' 68 | config.app_name = 'my-test-app' 69 | config.instance_id = 'rspec/test' 70 | config.custom_http_headers = { 'X-API-KEY' => '123' } 71 | end 72 | 73 | unleash_client = Unleash::Client.new( 74 | url: 'http://test-url/', 75 | app_name: 'my-test-app', 76 | instance_id: 'rspec/test', 77 | custom_http_headers: { 'X-API-KEY' => '123' } 78 | ) 79 | 80 | expect(unleash_client).to be_a(Unleash::Client) 81 | 82 | expect( 83 | a_request(:post, "http://test-url/client/register") 84 | .with(headers: { 'Content-Type': 'application/json' }) 85 | .with(headers: { 'X-API-KEY': '123', 'Content-Type': 'application/json' }) 86 | .with(headers: { 'UNLEASH-APPNAME': 'my-test-app' }) 87 | .with(headers: { 'UNLEASH-INSTANCEID': 'rspec/test' }) 88 | .with(headers: { 'UNLEASH-CONNECTION-ID': fixed_uuid }) 89 | ).to have_been_made.once 90 | 91 | expect( 92 | a_request(:get, "http://test-url/client/features") 93 | .with(headers: { 'X-API-KEY': '123' }) 94 | .with(headers: { 'UNLEASH-APPNAME': 'my-test-app' }) 95 | .with(headers: { 'UNLEASH-INSTANCEID': 'rspec/test' }) 96 | .with(headers: { 'UNLEASH-CONNECTION-ID': fixed_uuid }) 97 | .with(headers: { 'UNLEASH-INTERVAL': '15' }) 98 | ).to have_been_made.once 99 | 100 | # Test now sending of metrics 101 | # Not sending metrics, if no feature flags were evaluated: 102 | Unleash.reporter.post 103 | expect( 104 | a_request(:post, "http://test-url/client/metrics") 105 | .with(headers: { 'Content-Type': 'application/json' }) 106 | .with(headers: { 'X-API-KEY': '123', 'Content-Type': 'application/json' }) 107 | .with(headers: { 'UNLEASH-APPNAME': 'my-test-app' }) 108 | .with(headers: { 'UNLEASH-INSTANCEID': 'rspec/test' }) 109 | .with(headers: { 'UNLEASH-CONNECTION-ID': fixed_uuid }) 110 | .with(headers: { 'UNLEASH-INTERVAL': '60' }) 111 | ).not_to have_been_made 112 | 113 | # Sending metrics, if they have been evaluated: 114 | unleash_client.is_enabled?("Feature.A") 115 | unleash_client.get_variant("Feature.A") 116 | Unleash.reporter.post 117 | expect( 118 | a_request(:post, "http://test-url/client/metrics") 119 | .with(headers: { 'Content-Type': 'application/json' }) 120 | .with(headers: { 'X-API-KEY': '123', 'Content-Type': 'application/json' }) 121 | .with(headers: { 'UNLEASH-APPNAME': 'my-test-app' }) 122 | .with(headers: { 'UNLEASH-INSTANCEID': 'rspec/test' }) 123 | .with(headers: { 'UNLEASH-CONNECTION-ID': fixed_uuid }) 124 | .with(headers: { 'UNLEASH-INTERVAL': '60' }) 125 | .with{ |request| JSON.parse(request.body)['bucket']['toggles']['Feature.A']['yes'] == 2 } 126 | ).to have_been_made.once 127 | end 128 | 129 | it "should load/use correct variants from the unleash server" do 130 | WebMock.stub_request(:post, "http://test-url/client/register") 131 | .with( 132 | headers: { 133 | 'Accept' => '*/*', 134 | 'Content-Type' => 'application/json', 135 | 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 136 | 'Unleash-Appname' => 'my-test-app', 137 | 'Unleash-Instanceid' => 'rspec/test', 138 | 'User-Agent' => "UnleashClientRuby/#{Unleash::VERSION} #{RUBY_ENGINE}/#{RUBY_VERSION} [#{RUBY_PLATFORM}]", 139 | 'Unleash-Sdk' => "unleash-client-ruby:#{Unleash::VERSION}", 140 | 'X-Api-Key' => '123' 141 | } 142 | ) 143 | .to_return(status: 200, body: "", headers: {}) 144 | 145 | features_response_body = '{ 146 | "version": 1, 147 | "features": [ 148 | "name": "toggleName", 149 | "enabled": true, 150 | "strategies": [{ "name": "default" }], 151 | "variants": [ 152 | { 153 | "name": "a", 154 | "weight": 50, 155 | "payload": { 156 | "type": "string", 157 | "value": "" 158 | } 159 | }, 160 | { 161 | "name": "b", 162 | "weight": 50, 163 | "payload": { 164 | "type": "string", 165 | "value": "" 166 | } 167 | } 168 | ] 169 | ] 170 | }' 171 | 172 | WebMock.stub_request(:get, "http://test-url/client/features") 173 | .with( 174 | headers: { 175 | 'Accept' => '*/*', 176 | 'Content-Type' => 'application/json', 177 | 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 178 | 'Unleash-Appname' => 'my-test-app', 179 | 'Unleash-Instanceid' => 'rspec/test', 180 | 'User-Agent' => "UnleashClientRuby/#{Unleash::VERSION} #{RUBY_ENGINE}/#{RUBY_VERSION} [#{RUBY_PLATFORM}]", 181 | 'Unleash-Sdk' => "unleash-client-ruby:#{Unleash::VERSION}", 182 | 'X-Api-Key' => '123', 183 | 'Unleash-Interval' => '15' 184 | } 185 | ) 186 | .to_return(status: 200, body: features_response_body, headers: {}) 187 | 188 | Unleash.configure do |config| 189 | config.url = 'http://test-url/' 190 | config.app_name = 'my-test-app' 191 | config.instance_id = 'rspec/test' 192 | config.disable_metrics = true 193 | config.custom_http_headers = { 'X-API-KEY' => '123' } 194 | config.log_level = Logger::DEBUG 195 | end 196 | 197 | unleash_client = Unleash::Client.new 198 | 199 | expect( 200 | unleash_client.is_enabled?('toggleName', {}, true) 201 | ).to eq(true) 202 | 203 | expect(WebMock).not_to have_requested(:get, 'http://test-url/') 204 | expect(WebMock).to have_requested(:post, 'http://test-url/client/register') 205 | expect(WebMock).to have_requested(:get, 'http://test-url/client/features') 206 | end 207 | 208 | it "should load/use correct variants from a bootstrap source" do 209 | bootstrap_values = '{ 210 | "version": 1, 211 | "features": [ 212 | { 213 | "name": "featureX", 214 | "enabled": true, 215 | "strategies": [{ "name": "default" }] 216 | }, 217 | { 218 | "enabled": true, 219 | "name": "featureVariantX", 220 | "strategies": [{ "name": "default" }], 221 | "variants": [ 222 | { 223 | "name": "default-value", 224 | "payload": { 225 | "type": "string", 226 | "value": "info" 227 | }, 228 | "stickiness": "custom_attribute", 229 | "weight": 100, 230 | "weightType": "variable" 231 | } 232 | ] 233 | } 234 | ] 235 | }' 236 | 237 | Unleash.configure do |config| 238 | config.url = 'http://test-url/' 239 | config.app_name = 'my-test-app' 240 | config.instance_id = 'rspec/test' 241 | config.disable_client = true 242 | config.disable_metrics = true 243 | config.custom_http_headers = { 'X-API-KEY' => '123' } 244 | config.log_level = Logger::DEBUG 245 | config.bootstrap_config = Unleash::Bootstrap::Configuration.new({ 'data' => bootstrap_values }) 246 | end 247 | 248 | expect(Unleash.configuration.bootstrap_config.data).to eq(bootstrap_values) 249 | 250 | unleash_client = Unleash::Client.new 251 | expect( 252 | unleash_client.is_enabled?('featureX', {}, false) 253 | ).to be true 254 | 255 | default_variant = Unleash::Variant.new( 256 | name: 'featureVariantX', 257 | enabled: false, 258 | payload: { type: 'string', value: 'bogus' } 259 | ) 260 | variant = unleash_client.get_variant('featureVariantX', nil, default_variant) 261 | expect(variant.enabled).to be true 262 | expect(variant.payload.fetch('value')).to eq('info') 263 | 264 | expect(WebMock).not_to have_requested(:get, 'http://test-url/') 265 | expect(WebMock).not_to have_requested(:post, 'http://test-url/client/register') 266 | expect(WebMock).not_to have_requested(:get, 'http://test-url/client/features') 267 | 268 | # No requests at all: 269 | expect(WebMock).not_to have_requested(:get, /.*/) 270 | expect(WebMock).not_to have_requested(:post, /.*/) 271 | end 272 | 273 | it "should not fail if we are provided no toggles from the unleash server" do 274 | WebMock.stub_request(:post, "http://test-url/client/register") 275 | .with( 276 | headers: { 277 | 'Accept' => '*/*', 278 | 'Content-Type' => 'application/json', 279 | 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 280 | 'User-Agent' => "UnleashClientRuby/#{Unleash::VERSION} #{RUBY_ENGINE}/#{RUBY_VERSION} [#{RUBY_PLATFORM}]", 281 | 'Unleash-Sdk' => "unleash-client-ruby:#{Unleash::VERSION}", 282 | 'X-Api-Key' => '123' 283 | } 284 | ) 285 | .to_return(status: 200, body: "", headers: {}) 286 | 287 | WebMock.stub_request(:get, "http://test-url/client/features") 288 | .with( 289 | headers: { 290 | 'Accept' => '*/*', 291 | 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 292 | 'Content-Type' => 'application/json', 293 | 'Unleash-Appname' => 'my-test-app', 294 | 'Unleash-Instanceid' => 'rspec/test', 295 | 'User-Agent' => "UnleashClientRuby/#{Unleash::VERSION} #{RUBY_ENGINE}/#{RUBY_VERSION} [#{RUBY_PLATFORM}]", 296 | 'Unleash-Sdk' => "unleash-client-ruby:#{Unleash::VERSION}", 297 | 'X-Api-Key' => '123', 298 | 'Unleash-Interval' => '15' 299 | } 300 | ) 301 | .to_return(status: 200, body: "", headers: {}) 302 | 303 | Unleash.configure do |config| 304 | config.url = 'http://test-url/' 305 | config.app_name = 'my-test-app' 306 | config.instance_id = 'rspec/test' 307 | config.disable_client = false 308 | config.disable_metrics = true 309 | config.custom_http_headers = { 'X-API-KEY' => '123' } 310 | end 311 | 312 | unleash_client = Unleash::Client.new 313 | 314 | expect( 315 | unleash_client.is_enabled?('any_feature', {}, true) 316 | ).to eq(true) 317 | 318 | expect(WebMock).not_to have_requested(:get, 'http://test-url/') 319 | expect(WebMock).to have_requested(:get, 'http://test-url/client/features') 320 | expect(WebMock).to have_requested(:post, 'http://test-url/client/register') 321 | expect(WebMock).not_to have_requested(:post, 'http://test-url/client/metrics') 322 | end 323 | 324 | it "should not fail if we are provided no variants from the unleash server" do 325 | WebMock.stub_request(:post, "http://test-url/client/register") 326 | .with( 327 | headers: { 328 | 'Accept' => '*/*', 329 | 'Content-Type' => 'application/json', 330 | 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 331 | 'User-Agent' => "UnleashClientRuby/#{Unleash::VERSION} #{RUBY_ENGINE}/#{RUBY_VERSION} [#{RUBY_PLATFORM}]", 332 | 'Unleash-Sdk' => "unleash-client-ruby:#{Unleash::VERSION}", 333 | 'X-Api-Key' => '123' 334 | } 335 | ) 336 | .to_return(status: 200, body: "", headers: {}) 337 | 338 | features_response_body = '{ 339 | "version": 1, 340 | "features": [{ 341 | "name": "toggleName", 342 | "enabled": true, 343 | "strategies": [{ "name": "default", "constraints": [], "parameters": {}, "variants": null }], 344 | "variants": [] 345 | }] 346 | }' 347 | 348 | WebMock.stub_request(:get, "http://test-url/client/features") 349 | .with( 350 | headers: { 351 | 'Accept' => '*/*', 352 | 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 353 | 'Content-Type' => 'application/json', 354 | 'Unleash-Appname' => 'my-test-app', 355 | 'Unleash-Instanceid' => 'rspec/test', 356 | 'User-Agent' => "UnleashClientRuby/#{Unleash::VERSION} #{RUBY_ENGINE}/#{RUBY_VERSION} [#{RUBY_PLATFORM}]", 357 | 'Unleash-Sdk' => "unleash-client-ruby:#{Unleash::VERSION}", 358 | 'X-Api-Key' => '123', 359 | 'Unleash-Interval' => '15' 360 | } 361 | ) 362 | .to_return(status: 200, body: features_response_body, headers: {}) 363 | 364 | Unleash.configure do |config| 365 | config.url = 'http://test-url/' 366 | config.app_name = 'my-test-app' 367 | config.instance_id = 'rspec/test' 368 | config.disable_client = false 369 | config.disable_metrics = true 370 | config.custom_http_headers = { 'X-API-KEY' => '123' } 371 | end 372 | 373 | unleash_client = Unleash::Client.new 374 | 375 | expect(unleash_client.is_enabled?('toggleName', {})).to be true 376 | 377 | expect(WebMock).not_to have_requested(:get, 'http://test-url/') 378 | expect(WebMock).to have_requested(:get, 'http://test-url/client/features') 379 | expect(WebMock).to have_requested(:post, 'http://test-url/client/register') 380 | expect(WebMock).not_to have_requested(:post, 'http://test-url/client/metrics') 381 | end 382 | 383 | it "should forcefully disable metrics if the client is disabled" do 384 | Unleash.configure do |config| 385 | config.url = 'http://test-url/' 386 | config.app_name = 'my-test-app' 387 | config.instance_id = 'rspec/test' 388 | config.disable_client = true 389 | config.disable_metrics = false 390 | config.custom_http_headers = { 'X-API-KEY' => '123' } 391 | end 392 | 393 | unleash_client = Unleash::Client.new 394 | 395 | expect( 396 | unleash_client.is_enabled?('any_feature', {}, true) 397 | ).to eq(true) 398 | 399 | expect(Unleash.configuration.disable_client).to be true 400 | expect(Unleash.configuration.disable_metrics).to be true 401 | 402 | # No requests at all: 403 | expect(WebMock).not_to have_requested(:get, /.*/) 404 | expect(WebMock).not_to have_requested(:post, /.*/) 405 | end 406 | 407 | it "should return default results via block or param if running with disable_client" do 408 | Unleash.configure do |config| 409 | config.disable_client = true 410 | end 411 | unleash_client = Unleash::Client.new 412 | 413 | expect( 414 | unleash_client.is_enabled?('any_feature') 415 | ).to be false 416 | 417 | expect( 418 | unleash_client.is_enabled?('any_feature', {}, true) 419 | ).to be true 420 | 421 | expect( 422 | unleash_client.is_enabled?('any_feature2', {}, false) 423 | ).to be false 424 | 425 | expect( 426 | unleash_client.is_enabled?('any_feature3') { true } 427 | ).to be true 428 | 429 | expect( 430 | unleash_client.is_enabled?('any_feature3') { false } 431 | ).to be false 432 | 433 | expect( 434 | unleash_client.is_enabled?('any_feature3', {}) { true } 435 | ).to be true 436 | 437 | expect( 438 | unleash_client.is_enabled?('any_feature3', {}) { false } 439 | ).to be false 440 | 441 | expect( 442 | unleash_client.is_enabled?('any_feature5', {}) { nil } 443 | ).to be false 444 | 445 | # should never really send both the default value and a default block, 446 | # but if it is done, we OR the two values 447 | expect( 448 | unleash_client.is_enabled?('any_feature3', {}, true) { true } 449 | ).to be true 450 | 451 | expect( 452 | unleash_client.is_enabled?('any_feature3', {}, false) { true } 453 | ).to be true 454 | 455 | expect( 456 | unleash_client.is_enabled?('any_feature3', {}, true) { false } 457 | ).to be true 458 | 459 | expect( 460 | unleash_client.is_enabled?('any_feature3', {}, false) { false } 461 | ).to be false 462 | 463 | expect( 464 | unleash_client.is_enabled?('any_feature5', {}) { 'random_string' } 465 | ).to be true # expect "a string".to be_truthy 466 | 467 | expect do |b| 468 | unleash_client.is_enabled?('any_feature3', &b).to yield_with_no_args 469 | end 470 | 471 | expect do |b| 472 | unleash_client.is_enabled?('any_feature3', {}, &b).to yield_with_no_args 473 | end 474 | 475 | expect do |b| 476 | unleash_client.is_enabled?('any_feature3', {}, true, &b).to yield_with_no_args 477 | end 478 | 479 | number_eight = 8 480 | expect( 481 | unleash_client.is_enabled?('any_feature5', {}) do 482 | number_eight >= 5 483 | end 484 | ).to be true 485 | 486 | expect( 487 | unleash_client.is_enabled?('any_feature5', {}) do 488 | number_eight < 5 489 | end 490 | ).to be false 491 | 492 | context_params = { 493 | session_id: 'verylongsesssionid', 494 | remote_address: '127.0.0.2', 495 | properties: { 496 | env: 'dev' 497 | } 498 | } 499 | unleash_context = Unleash::Context.new(context_params) 500 | expect( 501 | unleash_client.is_enabled?('any_feature6', unleash_context) do |feature, context| 502 | feature == 'any_feature6' && \ 503 | context.remote_address == '127.0.0.2' && context.session_id.length == 18 && context.properties[:env] == 'dev' 504 | end 505 | ).to be true 506 | 507 | proc = proc do |_feat, ctx| 508 | ctx.remote_address.starts_with?("127.0.0.") 509 | end 510 | expect( 511 | unleash_client.is_enabled?('any_feature6', unleash_context) { proc } 512 | ).to be true 513 | expect( 514 | unleash_client.is_enabled?('any_feature6', unleash_context, true) { proc } 515 | ).to be true 516 | expect( 517 | unleash_client.is_enabled?('any_feature6', unleash_context, false) { proc } 518 | ).to be true 519 | 520 | proc_feat = proc do |feat, _ctx| 521 | feat != 'feature6' 522 | end 523 | expect( 524 | unleash_client.is_enabled?('feature6', unleash_context, &proc_feat) 525 | ).to be false 526 | expect( 527 | unleash_client.is_enabled?('feature6', unleash_context, true, &proc_feat) 528 | ).to be true 529 | expect( 530 | unleash_client.is_enabled?('feature6', unleash_context, false, &proc_feat) 531 | ).to be false 532 | end 533 | 534 | it "should not connect anywhere if running with disable_client" do 535 | Unleash.configure do |config| 536 | config.disable_client = true 537 | config.url = 'http://test-url/' 538 | config.custom_http_headers = 'invalid_string' 539 | end 540 | 541 | unleash_client = Unleash::Client.new 542 | 543 | expect( 544 | unleash_client.is_enabled?('any_feature', {}, true) 545 | ).to eq(true) 546 | 547 | expect(WebMock).not_to have_requested(:get, 'http://test-url/') 548 | expect(WebMock).not_to have_requested(:get, 'http://test-url/client/features') 549 | expect(WebMock).not_to have_requested(:post, 'http://test-url/client/features') 550 | expect(WebMock).not_to have_requested(:post, 'http://test-url/client/register') 551 | expect(WebMock).not_to have_requested(:post, 'http://test-url/client/metrics') 552 | end 553 | 554 | it "should return correct default values" do 555 | unleash_client = Unleash::Client.new 556 | expect(unleash_client.is_enabled?('any_feature')).to eq(false) 557 | expect(unleash_client.is_enabled?('any_feature', {}, false)).to eq(false) 558 | expect(unleash_client.is_enabled?('any_feature', {}, true)).to eq(true) 559 | 560 | expect(unleash_client.enabled?('any_feature', {}, true)).to eq(true) 561 | expect(unleash_client.enabled?('any_feature', {}, false)).to eq(false) 562 | 563 | expect(unleash_client.is_disabled?('any_feature')).to eq(true) 564 | expect(unleash_client.is_disabled?('any_feature', {}, true)).to eq(true) 565 | expect(unleash_client.is_disabled?('any_feature', {}, false)).to eq(false) 566 | 567 | expect(unleash_client.disabled?('any_feature', {}, true)).to eq(true) 568 | expect(unleash_client.disabled?('any_feature', {}, false)).to eq(false) 569 | end 570 | 571 | it "should yield correctly to block when using if_enabled" do 572 | unleash_client = Unleash::Client.new 573 | cont = Unleash::Context.new(user_id: 1) 574 | 575 | expect{ |b| unleash_client.if_enabled('any_feature', {}, true, &b).to yield_with_no_args } 576 | expect{ |b| unleash_client.if_enabled('any_feature', cont, true, &b).to yield_with_no_args } 577 | expect{ |b| unleash_client.if_enabled('any_feature', {}, false, &b).not_to yield_with_no_args } 578 | end 579 | 580 | it "should yield correctly to block when using if_disabled" do 581 | unleash_client = Unleash::Client.new 582 | cont = Unleash::Context.new(user_id: 1) 583 | 584 | expect{ |b| unleash_client.if_disabled('any_feature', {}, true, &b).not_to yield_with_no_args } 585 | expect{ |b| unleash_client.if_disabled('any_feature', cont, true, &b).not_to yield_with_no_args } 586 | 587 | expect{ |b| unleash_client.if_disabled('any_feature', {}, false, &b).to yield_with_no_args } 588 | expect{ |b| unleash_client.if_disabled('any_feature', cont, false, &b).to yield_with_no_args } 589 | expect{ |b| unleash_client.if_disabled('any_feature', {}, &b).to yield_with_no_args } 590 | expect{ |b| unleash_client.if_disabled('any_feature', &b).to yield_with_no_args } 591 | end 592 | 593 | describe 'get_variant' do 594 | let(:disable_client) { false } 595 | let(:client) { Unleash::Client.new } 596 | let(:feature) { 'awesome-feature' } 597 | let(:fallback_variant) { Unleash::Variant.new(name: 'default', enabled: true) } 598 | let(:variants) do 599 | [ 600 | { 601 | name: "a", 602 | weight: 50, 603 | stickiness: "default", 604 | payload: { 605 | type: "string", 606 | value: "" 607 | } 608 | } 609 | ] 610 | end 611 | let(:body) do 612 | { 613 | version: 1, 614 | features: [ 615 | { 616 | name: feature, 617 | enabled: true, 618 | strategies: [ 619 | { name: "default" } 620 | ], 621 | variants: variants 622 | } 623 | ] 624 | }.to_json 625 | end 626 | 627 | before do 628 | WebMock.stub_request(:post, "http://test-url/client/register") 629 | .with( 630 | headers: { 631 | 'Accept' => '*/*', 632 | 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 633 | 'Content-Type' => 'application/json', 634 | 'Unleash-Appname' => 'my-test-app', 635 | 'Unleash-Instanceid' => 'rspec/test', 636 | 'User-Agent' => "UnleashClientRuby/#{Unleash::VERSION} #{RUBY_ENGINE}/#{RUBY_VERSION} [#{RUBY_PLATFORM}]", 637 | 'Unleash-Sdk' => "unleash-client-ruby:#{Unleash::VERSION}", 638 | 'X-Api-Key' => '123' 639 | } 640 | ) 641 | .to_return(status: 200, body: '', headers: {}) 642 | 643 | WebMock.stub_request(:get, "http://test-url/client/features") 644 | .with( 645 | headers: { 646 | 'Accept' => '*/*', 647 | 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 648 | 'Content-Type' => 'application/json', 649 | 'Unleash-Appname' => 'my-test-app', 650 | 'Unleash-Instanceid' => 'rspec/test', 651 | 'User-Agent' => "UnleashClientRuby/#{Unleash::VERSION} #{RUBY_ENGINE}/#{RUBY_VERSION} [#{RUBY_PLATFORM}]", 652 | 'Unleash-Sdk' => "unleash-client-ruby:#{Unleash::VERSION}", 653 | 'X-Api-Key' => '123', 654 | 'Unleash-Interval' => '15' 655 | } 656 | ) 657 | .to_return(status: 200, body: body, headers: {}) 658 | 659 | Unleash.configure do |config| 660 | config.url = 'http://test-url/' 661 | config.app_name = 'my-test-app' 662 | config.disable_client = disable_client 663 | config.custom_http_headers = { 'X-API-KEY' => '123' } 664 | end 665 | end 666 | 667 | it 'returns variant' do 668 | ret = client.get_variant(feature) 669 | expect(ret.name).to eq 'a' 670 | end 671 | 672 | context 'when disable_client is false' do 673 | let(:disable_client) { true } 674 | 675 | context 'when fallback variant is specified' do 676 | it 'returns given fallback variant' do 677 | expect(client.get_variant(feature, nil, fallback_variant)).to be fallback_variant 678 | end 679 | end 680 | 681 | context 'when fallback variant is not specified' do 682 | it 'returns a disabled variant' do 683 | ret = client.get_variant(feature) 684 | expect(ret.enabled).to be false 685 | expect(ret.name).to eq 'disabled' 686 | end 687 | end 688 | end 689 | 690 | context 'when feature is not found' do 691 | context 'when fallback variant is specified' do 692 | it 'returns given fallback variant' do 693 | expect(client.get_variant('something', nil, fallback_variant)).to be fallback_variant 694 | end 695 | end 696 | 697 | context 'when fallback variant is not specified' do 698 | it 'returns a disabled variant' do 699 | ret = client.get_variant('something') 700 | expect(ret.enabled).to be false 701 | expect(ret.name).to eq 'disabled' 702 | end 703 | end 704 | end 705 | 706 | context 'when feature does not have variants' do 707 | let(:variants) { [] } 708 | 709 | it 'returns a disabled variant' do 710 | ret = client.get_variant(feature) 711 | expect(ret.enabled).to be false 712 | expect(ret.name).to eq 'disabled' 713 | end 714 | end 715 | end 716 | 717 | it "should use custom strategies during evaluation" do 718 | bootstrap_values = '{ 719 | "version": 1, 720 | "features": [ 721 | { 722 | "name": "featureX", 723 | "enabled": true, 724 | "strategies": [{ "name": "customStrategy" }] 725 | } 726 | ] 727 | }' 728 | 729 | class TestStrategy 730 | attr_reader :name 731 | 732 | def initialize(name) 733 | @name = name 734 | end 735 | 736 | def enabled?(_params, context) 737 | context.user_id == "123" 738 | end 739 | end 740 | 741 | Unleash.configure do |config| 742 | config.app_name = 'my-test-app' 743 | config.instance_id = 'rspec/test' 744 | config.disable_client = true 745 | config.disable_metrics = true 746 | config.bootstrap_config = Unleash::Bootstrap::Configuration.new({ 'data' => bootstrap_values }) 747 | config.strategies.add(TestStrategy.new('customStrategy')) 748 | end 749 | 750 | context_params = { 751 | user_id: '123' 752 | } 753 | unleash_context = Unleash::Context.new(context_params) 754 | 755 | unleash_client = Unleash::Client.new 756 | expect( 757 | unleash_client.is_enabled?('featureX', unleash_context) 758 | ).to be true 759 | 760 | expect( 761 | unleash_client.is_enabled?('featureX', Unleash::Context.new({})) 762 | ).to be false 763 | end 764 | end 765 | -------------------------------------------------------------------------------- /spec/unleash/client_specification_spec.rb: -------------------------------------------------------------------------------- 1 | require 'unleash' 2 | require 'unleash/client' 3 | require 'unleash/configuration' 4 | require 'unleash/variant' 5 | 6 | RSpec.describe Unleash::Client do 7 | # load client spec 8 | SPECIFICATION_PATH = 'client-specification/specifications'.freeze 9 | 10 | before do 11 | Unleash.configuration = Unleash::Configuration.new 12 | 13 | Unleash.logger = Unleash.configuration.logger 14 | Unleash.logger.level = Unleash.configuration.log_level 15 | 16 | Unleash.configuration.disable_metrics = true 17 | end 18 | 19 | unless File.exist?(SPECIFICATION_PATH + '/index.json') 20 | raise "Client specification tests not found, these are mandatory for a successful test run. "\ 21 | "You can download the client specification by running the following command:\n "\ 22 | "`git clone --branch v$(ruby echo_client_spec_version.rb) https://github.com/Unleash/client-specification.git`" 23 | end 24 | 25 | JSON.parse(File.read(SPECIFICATION_PATH + '/index.json')).each do |test_file| 26 | describe "for #{test_file}" do 27 | ## Encoding is set in this read purely for JRuby. Don't take this out, it'll work locally and then fail on CI 28 | current_test_set = JSON.parse(File.read(SPECIFICATION_PATH + '/' + test_file, encoding: 'utf-8')) 29 | context "with #{current_test_set.fetch('name')} " do 30 | tests = current_test_set.fetch('tests', []) 31 | tests.each do |test| 32 | it "test that #{test['description']}" do 33 | context = Unleash::Context.new(test['context']) 34 | 35 | unleash = Unleash::Client.new( 36 | disable_client: true, 37 | disable_metrics: true, 38 | bootstrap_config: Unleash::Bootstrap::Configuration.new(data: current_test_set.fetch('state', {}).to_json) 39 | ) 40 | toggle_result = unleash.is_enabled?(test.fetch('toggleName'), context) 41 | 42 | expect(toggle_result).to eq(test['expectedResult']) 43 | end 44 | end 45 | 46 | variant_tests = current_test_set.fetch('variantTests', []) 47 | variant_tests.each do |test| 48 | it "test that #{test['description']}" do 49 | context = Unleash::Context.new(test['context']) 50 | 51 | unleash = Unleash::Client.new( 52 | disable_client: true, 53 | disable_metrics: true, 54 | bootstrap_config: Unleash::Bootstrap::Configuration.new(data: current_test_set.fetch('state', {}).to_json) 55 | ) 56 | variant = unleash.get_variant(test.fetch('toggleName'), context) 57 | 58 | expect(variant).to eq(Unleash::Variant.new(test['expectedResult'])) 59 | end 60 | end 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/unleash/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | require "unleash/configuration" 2 | require "securerandom" 3 | 4 | RSpec.describe Unleash do 5 | describe 'Configuration' do 6 | before do 7 | Unleash.configuration = Unleash::Configuration.new 8 | end 9 | 10 | it "should have the correct defaults" do 11 | config = Unleash::Configuration.new 12 | 13 | expect(config.app_name).to be_nil 14 | expect(config.environment).to eq('default') 15 | expect(config.url).to be_nil 16 | expect(config.instance_id).to be_truthy 17 | expect(config.custom_http_headers).to eq({}) 18 | expect(config.disable_metrics).to be_falsey 19 | 20 | expect(config.refresh_interval).to eq(15) 21 | expect(config.metrics_interval).to eq(60) 22 | expect(config.timeout).to eq(30) 23 | expect(config.retry_limit).to eq(Float::INFINITY) 24 | 25 | expect(config.backup_file).to_not be_nil 26 | expect(config.backup_file).to eq(Dir.tmpdir + '/unleash--repo.json') 27 | expect(config.project_name).to be_nil 28 | 29 | expect(config.strategies).to be_instance_of(Unleash::Strategies) 30 | 31 | expect{ config.validate! }.to raise_error(ArgumentError) 32 | end 33 | 34 | it "should by default be invalid" do 35 | config = Unleash::Configuration.new 36 | expect{ config.validate! }.to raise_error(ArgumentError) 37 | end 38 | 39 | it "should be valid with the mandatory arguments set" do 40 | config = Unleash::Configuration.new(app_name: 'rspec_test', url: 'http://testurl/') 41 | expect{ config.validate! }.not_to raise_error 42 | end 43 | 44 | it "should be lenient if disable_client is true" do 45 | config = Unleash::Configuration.new(disable_client: true) 46 | expect{ config.validate! }.not_to raise_error 47 | end 48 | 49 | it "support setting the configuration via new" do 50 | config = Unleash::Configuration.new(app_name: 'rspec_test', url: 'http://testurl/') 51 | 52 | expect(config.app_name).to eq('rspec_test') 53 | expect(config.environment).to eq('default') 54 | expect(config.url).to eq('http://testurl/') 55 | expect(config.instance_id).to be_truthy 56 | expect(config.custom_http_headers).to eq({}) 57 | expect(config.disable_metrics).to be_falsey 58 | 59 | expect(config.backup_file).to eq(Dir.tmpdir + '/unleash-rspec_test-repo.json') 60 | expect(config.project_name).to be_nil 61 | 62 | expect{ config.validate! }.not_to raise_error 63 | end 64 | 65 | it "support yield for setting the configuration" do 66 | Unleash.configure do |config| 67 | config.url = 'http://test-url/' 68 | config.app_name = 'my-test-app' 69 | end 70 | expect{ Unleash.configuration.validate! }.not_to raise_error 71 | expect(Unleash.configuration.url).to eq('http://test-url/') 72 | expect(Unleash.configuration.app_name).to eq('my-test-app') 73 | expect(Unleash.configuration.backup_file).to eq(Dir.tmpdir + '/unleash-my-test-app-repo.json') 74 | expect(Unleash.configuration.fetch_toggles_uri.to_s).to eq('http://test-url/client/features') 75 | expect(Unleash.configuration.client_metrics_uri.to_s).to eq('http://test-url/client/metrics') 76 | expect(Unleash.configuration.client_register_uri.to_s).to eq('http://test-url/client/register') 77 | end 78 | 79 | it "should build the correct unleash endpoints from the base url" do 80 | config = Unleash::Configuration.new(url: 'https://testurl/api', app_name: 'test-app') 81 | expect(config.url).to eq('https://testurl/api') 82 | expect(config.fetch_toggles_uri.to_s).to eq('https://testurl/api/client/features') 83 | expect(config.client_metrics_uri.to_s).to eq('https://testurl/api/client/metrics') 84 | expect(config.client_register_uri.to_s).to eq('https://testurl/api/client/register') 85 | end 86 | 87 | it "should build the correct unleash endpoints from a base url ending with slash" do 88 | config = Unleash::Configuration.new(url: 'https://testurl/api/', app_name: 'test-app') 89 | expect(config.url).to eq('https://testurl/api/') 90 | expect(config.fetch_toggles_uri.to_s).to eq('https://testurl/api/client/features') 91 | expect(config.client_metrics_uri.to_s).to eq('https://testurl/api/client/metrics') 92 | expect(config.client_register_uri.to_s).to eq('https://testurl/api/client/register') 93 | end 94 | 95 | it "should build the correct unleash endpoints from a base url ending with double slashes" do 96 | config = Unleash::Configuration.new(url: 'https://testurl/api//', app_name: 'test-app') 97 | expect(config.url).to eq('https://testurl/api//') 98 | expect(config.fetch_toggles_uri.to_s).to eq('https://testurl/api//client/features') 99 | expect(config.client_metrics_uri.to_s).to eq('https://testurl/api//client/metrics') 100 | expect(config.client_register_uri.to_s).to eq('https://testurl/api//client/register') 101 | end 102 | 103 | it "should build the correct unleash features endpoint when project_name is used" do 104 | config = Unleash::Configuration.new(url: 'https://testurl/api', app_name: 'test-app', project_name: 'test-project') 105 | expect(config.fetch_toggles_uri.to_s).to eq('https://testurl/api/client/features?project=test-project') 106 | end 107 | 108 | it "should allow hashes for custom_http_headers via yield" do 109 | Unleash.configure do |config| 110 | config.url = 'http://test-url/' 111 | config.app_name = 'my-test-app' 112 | config.custom_http_headers = { 'X-API-KEY': '123' } 113 | end 114 | expect{ Unleash.configuration.validate! }.not_to raise_error 115 | expect(Unleash.configuration.custom_http_headers).to eq({ 'X-API-KEY': '123' }) 116 | end 117 | 118 | it "should allow hashes for custom_http_headers via new client" do 119 | config = Unleash::Configuration.new( 120 | url: 'https://testurl/api', 121 | app_name: 'test-app', 122 | custom_http_headers: { 'X-API-KEY': '123', 'UNLEASH-CONNECTION-ID': 'ignore' } 123 | ) 124 | 125 | expect{ config.validate! }.not_to raise_error 126 | expect(config.custom_http_headers).to include({ 'X-API-KEY': '123' }) 127 | expect(config.http_headers).to include({ 'UNLEASH-APPNAME' => 'test-app' }) 128 | expect(config.http_headers).to include('UNLEASH-INSTANCEID') 129 | expect(config.http_headers).to include('UNLEASH-CONNECTION-ID') 130 | end 131 | 132 | it "should allow lambdas and procs for custom_https_headers via new client" do 133 | custom_headers_proc = proc do 134 | { 'X-API-KEY' => '123' } 135 | end 136 | allow(custom_headers_proc).to receive(:call).and_call_original 137 | 138 | fixed_uuid = "123e4567-e89b-12d3-a456-426614174000" 139 | allow(SecureRandom).to receive(:uuid).and_return(fixed_uuid) 140 | 141 | config = Unleash::Configuration.new( 142 | url: 'https://testurl/api', 143 | app_name: 'test-app', 144 | custom_http_headers: custom_headers_proc 145 | ) 146 | 147 | expect{ config.validate! }.not_to raise_error 148 | expect(config.custom_http_headers).to be_a(Proc) 149 | expect(config.http_headers).to eq( 150 | { 151 | 'X-API-KEY' => '123', 152 | 'UNLEASH-APPNAME' => 'test-app', 153 | 'UNLEASH-INSTANCEID' => config.instance_id, 154 | 'UNLEASH-CONNECTION-ID' => fixed_uuid, 155 | 'UNLEASH-SDK' => "unleash-client-ruby:#{Unleash::VERSION}", 156 | 'Unleash-Client-Spec' => '5.2.0', 157 | 'User-Agent' => "UnleashClientRuby/#{Unleash::VERSION} #{RUBY_ENGINE}/#{RUBY_VERSION} [#{RUBY_PLATFORM}]" 158 | } 159 | ) 160 | expect(custom_headers_proc).to have_received(:call).exactly(1).times 161 | end 162 | 163 | it "should not accept invalid custom_http_headers via yield" do 164 | expect do 165 | Unleash.configure do |config| 166 | config.url = 'http://test-url/' 167 | config.app_name = 'my-test-app' 168 | config.custom_http_headers = 123.456 169 | end 170 | end.to raise_error(ArgumentError, "custom_http_headers must be a Hash or a Proc.") 171 | end 172 | 173 | it "should not accept invalid custom_http_headers via new client" do 174 | WebMock \ 175 | .stub_request(:post, "http://test-url//client/register") 176 | .with( 177 | headers: { 178 | 'Accept' => '*/*', 179 | 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 180 | 'Content-Type' => 'application/json', 181 | 'User-Agent' => 'Ruby' 182 | } 183 | ) 184 | .to_return(status: 200, body: "", headers: {}) 185 | 186 | expect do 187 | Unleash::Client.new( 188 | url: 'https://testurl/api', 189 | app_name: 'test-app', 190 | custom_http_headers: 123.0, 191 | disable_metrics: true 192 | ) 193 | end.to raise_error(ArgumentError) 194 | end 195 | 196 | it "should send metadata on registration" do 197 | WebMock \ 198 | .stub_request(:get, "http://test-url/api/client/features") 199 | .to_return(status: 200, body: "", headers: {}) 200 | 201 | WebMock \ 202 | .stub_request(:post, "http://test-url/api/client/register") 203 | .to_return(status: 200, body: "", headers: {}) 204 | 205 | Unleash::Client.new( 206 | url: 'http://test-url/api', 207 | app_name: 'test-app' 208 | ) 209 | 210 | expect(WebMock).to have_requested(:post, 'http://test-url/api/client/register') 211 | .with( 212 | body: hash_including( 213 | yggdrasilVersion: anything, 214 | specVersion: anything, 215 | platformName: anything, 216 | platformVersion: anything, 217 | connectionId: anything 218 | ) 219 | ) 220 | end 221 | end 222 | end 223 | -------------------------------------------------------------------------------- /spec/unleash/context_spec.rb: -------------------------------------------------------------------------------- 1 | require 'unleash/context' 2 | 3 | RSpec.describe Unleash::Context do 4 | before do 5 | Unleash.configuration = Unleash::Configuration.new 6 | end 7 | 8 | context 'parameters correctly assigned in initialization' 9 | 10 | it "when using snake_case" do 11 | params = { 12 | user_id: '123', 13 | session_id: 'verylongsesssionid', 14 | remote_address: '127.0.0.2', 15 | properties: { 16 | fancy: 'polarbear' 17 | } 18 | } 19 | context = Unleash::Context.new(params) 20 | expect(context.user_id).to eq('123') 21 | expect(context.session_id).to eq('verylongsesssionid') 22 | expect(context.remote_address).to eq('127.0.0.2') 23 | expect(context.properties).to eq({ fancy: 'polarbear' }) 24 | end 25 | 26 | it "when using camelCase" do 27 | params = { 28 | 'userId' => '123', 29 | 'sessionId' => 'verylongsesssionid', 30 | 'remoteAddress' => '127.0.0.2', 31 | 'properties' => { 32 | 'fancy' => 'polarbear' 33 | } 34 | } 35 | context = Unleash::Context.new(params) 36 | expect(context.user_id).to eq('123') 37 | expect(context.session_id).to eq('verylongsesssionid') 38 | expect(context.remote_address).to eq('127.0.0.2') 39 | expect(context.properties).to eq({ fancy: 'polarbear' }) 40 | end 41 | 42 | it "will ignore non hash properties" do 43 | params = { 'properties' => [1, 2, 3] } 44 | context = Unleash::Context.new(params) 45 | expect(context.properties).to eq({}) 46 | end 47 | 48 | it "will correctly use default values when using empty hash and client is not configured" do 49 | params = {} 50 | context = Unleash::Context.new(params) 51 | expect(context.app_name).to be_nil 52 | expect(context.environment).to eq('default') 53 | end 54 | 55 | it "will correctly use default values when using empty hash and client is configured" do 56 | Unleash.configure do |config| 57 | config.url = 'http://testurl/api' 58 | config.app_name = 'my_ruby_app' 59 | config.environment = 'dev' 60 | end 61 | 62 | params = {} 63 | context = Unleash::Context.new(params) 64 | expect(context.app_name).to eq('my_ruby_app') 65 | expect(context.environment).to eq('dev') 66 | end 67 | 68 | it "will correctly allow context config to overridde client configuration" do 69 | Unleash.configure do |config| 70 | config.url = 'http://testurl/api' 71 | config.app_name = 'my_ruby_app' 72 | config.environment = 'pre' 73 | end 74 | 75 | context = Unleash::Context.new( 76 | app_name: 'my_super_app', 77 | environment: 'test' 78 | ) 79 | expect(context.app_name).to eq('my_super_app') 80 | expect(context.environment).to eq('test') 81 | end 82 | 83 | it "when using get_by_name with keys as symbols" do 84 | params = { 85 | userId: '123', 86 | session_id: 'verylongsesssionid', 87 | properties: { 88 | fancy: 'polarbear', 89 | countryCode: 'DK' 90 | } 91 | } 92 | context = Unleash::Context.new(params) 93 | expect(context.get_by_name('user_id')).to eq('123') 94 | expect(context.get_by_name(:user_id)).to eq('123') 95 | expect(context.get_by_name('userId')).to eq('123') 96 | expect(context.get_by_name('UserId')).to eq('123') 97 | expect(context.get_by_name('session_id')).to eq('verylongsesssionid') 98 | expect(context.get_by_name(:session_id)).to eq('verylongsesssionid') 99 | expect(context.get_by_name('sessionId')).to eq('verylongsesssionid') 100 | expect(context.get_by_name('SessionId')).to eq('verylongsesssionid') 101 | expect(context.get_by_name(:fancy)).to eq('polarbear') 102 | expect(context.get_by_name('fancy')).to eq('polarbear') 103 | expect(context.get_by_name('Fancy')).to eq('polarbear') 104 | expect(context.get_by_name('countryCode')).to eq('DK') 105 | expect(context.get_by_name(:countryCode)).to eq('DK') 106 | expect{ context.get_by_name(:country_code) }.to raise_error(KeyError) 107 | expect{ context.get_by_name('country_code') }.to raise_error(KeyError) 108 | expect{ context.get_by_name('CountryCode') }.to raise_error(KeyError) 109 | expect{ context.get_by_name(:CountryCode) }.to raise_error(KeyError) 110 | end 111 | 112 | it "when using get_by_name with keys as strings" do 113 | params = { 114 | 'user_id' => '123', 115 | 'sessionId' => 'verylongsesssionid', 116 | 'properties' => { 117 | 'fancy' => 'polarbear', 118 | 'country_code' => 'UK' 119 | } 120 | } 121 | context = Unleash::Context.new(params) 122 | expect(context.get_by_name('user_id')).to eq('123') 123 | expect(context.get_by_name(:user_id)).to eq('123') 124 | expect(context.get_by_name('userId')).to eq('123') 125 | expect(context.get_by_name('UserId')).to eq('123') 126 | expect(context.get_by_name('session_id')).to eq('verylongsesssionid') 127 | expect(context.get_by_name(:session_id)).to eq('verylongsesssionid') 128 | expect(context.get_by_name('sessionId')).to eq('verylongsesssionid') 129 | expect(context.get_by_name('SessionId')).to eq('verylongsesssionid') 130 | expect(context.get_by_name(:fancy)).to eq('polarbear') 131 | expect(context.get_by_name('fancy')).to eq('polarbear') 132 | expect(context.get_by_name('Fancy')).to eq('polarbear') 133 | expect(context.get_by_name('country_code')).to eq('UK') 134 | expect(context.get_by_name(:country_code)).to eq('UK') 135 | expect(context.get_by_name('countryCode')).to eq('UK') 136 | expect(context.get_by_name(:countryCode)).to eq('UK') 137 | expect(context.get_by_name('CountryCode')).to eq('UK') 138 | expect(context.get_by_name(:CountryCode)).to eq('UK') 139 | end 140 | 141 | it "checks if property is included in the context" do 142 | params = { 143 | 'user_id' => '123', 144 | 'sessionId' => 'verylongsesssionid', 145 | 'properties' => { 146 | 'fancy' => 'polarbear', 147 | 'country_code' => 'UK' 148 | } 149 | } 150 | context = Unleash::Context.new(params) 151 | expect(context.include?(:user_id)).to be true 152 | expect(context.include?(:user_name)).to be false 153 | expect(context.include?(:session_id)).to be true 154 | expect(context.include?(:fancy)).to be true 155 | expect(context.include?(:country_code)).to be true 156 | expect(context.include?(:countryCode)).to be true 157 | expect(context.include?(:CountryCode)).to be true 158 | end 159 | 160 | it "creates default date for current time if not populated" do 161 | params = {} 162 | context = Unleash::Context.new(params) 163 | 164 | expect(context.get_by_name(:CurrentTime)).not_to eq(nil) 165 | end 166 | 167 | it "creates doesn't create a default date if passed in" do 168 | date = DateTime.now 169 | params = { 170 | 'currentTime': date 171 | } 172 | context = Unleash::Context.new(params) 173 | 174 | expect(context.get_by_name(:CurrentTime).to_s).to eq(date.to_s) 175 | end 176 | 177 | it "converts context to hash" do 178 | params = { 179 | app_name: 'my_ruby_app', 180 | environment: 'dev', 181 | user_id: '123', 182 | session_id: 'verylongsesssionid', 183 | remote_address: '127.0.0.2', 184 | current_time: '2023-03-22T00:00:00Z', 185 | properties: { 186 | fancy: 'polarbear' 187 | } 188 | } 189 | context = Unleash::Context.new(params) 190 | expect(context.to_h).to eq(params) 191 | end 192 | 193 | it "converts to json without error" do 194 | expect { JSON.dump(Unleash::Context.new) }.not_to raise_error 195 | end 196 | end 197 | -------------------------------------------------------------------------------- /spec/unleash/metrics_reporter_spec.rb: -------------------------------------------------------------------------------- 1 | require "rspec/json_expectations" 2 | 3 | RSpec.describe Unleash::MetricsReporter do 4 | let(:metrics_reporter) { Unleash::MetricsReporter.new } 5 | 6 | before do 7 | Unleash.configuration = Unleash::Configuration.new 8 | Unleash.logger = Unleash.configuration.logger 9 | Unleash.logger.level = Unleash.configuration.log_level 10 | # Unleash.logger.level = Logger::DEBUG 11 | 12 | Unleash.configuration.url = 'http://test-url/' 13 | Unleash.configuration.app_name = 'my-test-app' 14 | Unleash.configuration.instance_id = 'rspec/test' 15 | 16 | # Do not test the scheduled calls from client/metrics: 17 | Unleash.configuration.disable_client = true 18 | Unleash.configuration.disable_metrics = true 19 | metrics_reporter.last_time = Time.now 20 | end 21 | 22 | it "generates the correct report" do 23 | Unleash.configure do |config| 24 | config.url = 'http://test-url/' 25 | config.app_name = 'my-test-app' 26 | config.instance_id = 'rspec/test' 27 | config.disable_client = true 28 | end 29 | Unleash.engine = YggdrasilEngine.new 30 | 31 | Unleash.engine.count_toggle('featureA', true) 32 | Unleash.engine.count_toggle('featureA', true) 33 | Unleash.engine.count_toggle('featureA', true) 34 | Unleash.engine.count_toggle('featureA', false) 35 | Unleash.engine.count_toggle('featureA', false) 36 | Unleash.engine.count_toggle('featureB', true) 37 | 38 | report = metrics_reporter.generate_report 39 | expect(report[:bucket][:toggles]).to include( 40 | featureA: { 41 | no: 2, 42 | yes: 3, 43 | variants: {} 44 | }, 45 | featureB: { 46 | no: 0, 47 | yes: 1, 48 | variants: {} 49 | } 50 | ) 51 | 52 | expect(report[:bucket][:toggles].to_json).to include_json( 53 | featureA: { 54 | no: 2, 55 | yes: 3 56 | }, 57 | featureB: { 58 | no: 0, 59 | yes: 1 60 | } 61 | ) 62 | end 63 | 64 | it "sends the correct report" do 65 | WebMock.stub_request(:post, "http://test-url/client/metrics") 66 | .with( 67 | headers: { 68 | 'Accept' => '*/*', 69 | 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 70 | 'Content-Type' => 'application/json', 71 | 'Unleash-Appname' => 'my-test-app', 72 | 'Unleash-Instanceid' => 'rspec/test', 73 | 'User-Agent' => "UnleashClientRuby/#{Unleash::VERSION} #{RUBY_ENGINE}/#{RUBY_VERSION} [#{RUBY_PLATFORM}]", 74 | 'Unleash-Sdk' => "unleash-client-ruby:#{Unleash::VERSION}", 75 | 'Unleash-Interval' => "60" 76 | } 77 | ) 78 | .to_return(status: 200, body: "", headers: {}) 79 | 80 | Unleash.engine = YggdrasilEngine.new 81 | 82 | Unleash.engine.count_toggle('featureA', true) 83 | Unleash.engine.count_toggle('featureA', true) 84 | Unleash.engine.count_toggle('featureA', true) 85 | Unleash.engine.count_toggle('featureA', false) 86 | Unleash.engine.count_toggle('featureA', false) 87 | Unleash.engine.count_toggle('featureB', true) 88 | 89 | metrics_reporter.post 90 | 91 | expect(WebMock).to have_requested(:post, 'http://test-url/client/metrics') 92 | .with { |req| 93 | hash = JSON.parse(req.body) 94 | 95 | [ 96 | DateTime.parse(hash['bucket']['stop']) >= DateTime.parse(hash['bucket']['start']), 97 | hash['bucket']['toggles']['featureA']['yes'] == 3, 98 | hash['bucket']['toggles']['featureA']['no'] == 2, 99 | hash['bucket']['toggles']['featureB']['yes'] == 1 100 | ].all?(true) 101 | } 102 | .with( 103 | body: hash_including( 104 | appName: "my-test-app", 105 | instanceId: "rspec/test" 106 | ) 107 | ) 108 | end 109 | 110 | it "does not send a report, if there were no metrics registered/evaluated" do 111 | Unleash.engine = YggdrasilEngine.new 112 | 113 | metrics_reporter.post 114 | 115 | expect(WebMock).to_not have_requested(:post, 'http://test-url/client/metrics') 116 | end 117 | 118 | it "generates an empty report when no metrics after 10 minutes" do 119 | WebMock.stub_request(:post, "http://test-url/client/metrics") 120 | .to_return(status: 200, body: "", headers: {}) 121 | Unleash.configure do |config| 122 | config.url = 'http://test-url/' 123 | config.app_name = 'my-test-app' 124 | config.instance_id = 'rspec/test' 125 | config.disable_client = true 126 | end 127 | Unleash.engine = YggdrasilEngine.new 128 | 129 | metrics_reporter.last_time = Time.now - 601 130 | metrics_reporter.generate_report 131 | 132 | metrics_reporter.post 133 | 134 | expect(WebMock).to(have_requested(:post, 'http://test-url/client/metrics') 135 | .with do |req| 136 | body = JSON.parse(req.body, symbolize_names: true) 137 | 138 | expect(body).to include( 139 | platformName: anything, 140 | platformVersion: anything, 141 | yggdrasilVersion: anything, 142 | specVersion: anything, 143 | bucket: include( 144 | start: anything, 145 | stop: anything, 146 | toggles: {} 147 | ) 148 | ) 149 | 150 | true # tell WebMock we're done matching 151 | end) 152 | end 153 | 154 | it "includes metadata in the report" do 155 | WebMock.stub_request(:post, "http://test-url/client/metrics") 156 | .to_return(status: 200, body: "", headers: {}) 157 | 158 | Unleash.engine = YggdrasilEngine.new 159 | Unleash.engine.count_toggle('featureA', true) 160 | 161 | metrics_reporter.post 162 | 163 | expect(WebMock).to have_requested(:post, 'http://test-url/client/metrics') 164 | .with( 165 | body: hash_including( 166 | yggdrasilVersion: anything, 167 | specVersion: anything, 168 | platformName: anything, 169 | platformVersion: anything, 170 | connectionId: anything 171 | ) 172 | ) 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /spec/unleash/scheduled_executor_spec.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | 3 | RSpec.describe Unleash::ScheduledExecutor do 4 | # context 'parameters correctly assigned in initialization' 5 | it "can start and exit a thread" do 6 | scheduled_executor = Unleash::ScheduledExecutor.new('TesterLoop', 0.1) 7 | scheduled_executor.run do 8 | loop do 9 | sleep 0.1 10 | end 11 | end 12 | 13 | expect(scheduled_executor.running?).to be true 14 | scheduled_executor.exit 15 | expect(scheduled_executor.running?).to be false 16 | end 17 | 18 | # Test that it will correctly stop running after the provided number of exceptions 19 | it "will stop running after the configured number of failures" do 20 | max_exceptions = 2 21 | 22 | scheduled_executor = Unleash::ScheduledExecutor.new('TesterLoop', 0, max_exceptions) 23 | scheduled_executor.run do 24 | raise StopIteration 25 | end 26 | expect(scheduled_executor.thread).to_not be_nil 27 | 28 | scheduled_executor.thread.join 29 | 30 | expect(scheduled_executor.retry_count).to be == 1 + max_exceptions 31 | expect(scheduled_executor.running?).to be false 32 | end 33 | 34 | it "will run the correct code" do 35 | max_exceptions = 1 36 | 37 | scheduled_executor = Unleash::ScheduledExecutor.new('TesterLoop', 0, max_exceptions) 38 | new_instance_id = SecureRandom.uuid 39 | original_instance_id = Unleash.configuration.instance_id 40 | 41 | scheduled_executor.run do 42 | Unleash.configuration.instance_id = new_instance_id 43 | raise StopIteration 44 | end 45 | 46 | scheduled_executor.thread.join 47 | expect(Unleash.configuration.instance_id).to eq(new_instance_id) 48 | 49 | Unleash.configuration.instance_id = original_instance_id 50 | end 51 | 52 | # These two tests are super flaky because they're checking if threading works 53 | # We could extend the times to make them less flaky but that would mean slower tests so I'm disabling them for now 54 | xit "will trigger immediate exection when set to do so" do 55 | max_exceptions = 1 56 | 57 | scheduled_executor = Unleash::ScheduledExecutor.new('TesterLoop', 0.02, max_exceptions, true) 58 | new_instance_id = SecureRandom.uuid 59 | original_instance_id = Unleash.configuration.instance_id 60 | 61 | scheduled_executor.run do 62 | Unleash.configuration.instance_id = new_instance_id 63 | raise StopIteration 64 | end 65 | 66 | sleep 0.01 67 | 68 | expect(Unleash.configuration.instance_id).to eq(new_instance_id) 69 | scheduled_executor.thread.join 70 | 71 | Unleash.configuration.instance_id = original_instance_id 72 | end 73 | 74 | xit "will not trigger immediate exection when not set" do 75 | max_exceptions = 1 76 | 77 | scheduled_executor = Unleash::ScheduledExecutor.new('TesterLoop', 0.02, max_exceptions, false) 78 | new_instance_id = SecureRandom.uuid 79 | original_instance_id = Unleash.configuration.instance_id 80 | 81 | scheduled_executor.run do 82 | Unleash.configuration.instance_id = new_instance_id 83 | raise StopIteration 84 | end 85 | 86 | sleep 0.01 87 | 88 | expect(Unleash.configuration.instance_id).to eq(original_instance_id) 89 | scheduled_executor.thread.join 90 | 91 | Unleash.configuration.instance_id = original_instance_id 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/unleash/toggle_fetcher_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Unleash::ToggleFetcher do 2 | subject(:toggle_fetcher) { Unleash::ToggleFetcher.new YggdrasilEngine.new } 3 | 4 | before do 5 | Unleash.configure do |config| 6 | config.url = 'http://toggle-fetcher-test-url/' 7 | config.app_name = 'toggle-fetcher-my-test-app' 8 | config.disable_client = false # Some test is changing the process state for this one 9 | end 10 | 11 | fetcher_features = { 12 | "version": 1, 13 | "features": [ 14 | { 15 | "name": "Feature.A", 16 | "description": "Enabled toggle", 17 | "enabled": true, 18 | "strategies": [{ "name": "toggle-fetcher" }] 19 | } 20 | ], 21 | "segments": [ 22 | { 23 | "id": 1, 24 | "name": "test-segment", 25 | "description": "test-segment", 26 | "constraints": [ 27 | { 28 | "values": [ 29 | "7" 30 | ], 31 | "inverted": false, 32 | "operator": "IN", 33 | "contextName": "test", 34 | "caseInsensitive": false 35 | } 36 | ], 37 | "createdBy": "admin", 38 | "createdAt": "2022-09-02T00:00:00.000Z" 39 | } 40 | ] 41 | 42 | } 43 | 44 | WebMock.stub_request(:get, "http://toggle-fetcher-test-url/client/features") 45 | .to_return(status: 200, 46 | body: fetcher_features.to_json, 47 | headers: {}) 48 | 49 | Unleash.logger = Unleash.configuration.logger 50 | end 51 | 52 | after do 53 | WebMock.reset! 54 | File.delete(Unleash.configuration.backup_file) if File.exist?(Unleash.configuration.backup_file) 55 | end 56 | 57 | describe '#fetch!' do 58 | let(:engine) { YggdrasilEngine.new } 59 | 60 | context 'when fetching toggles succeeds' do 61 | before do 62 | _toggle_fetcher = described_class.new engine 63 | end 64 | it 'creates a file with toggle_cache in JSON' do 65 | backup_file = Unleash.configuration.backup_file 66 | expect(File.exist?(backup_file)).to eq(true) 67 | end 68 | end 69 | end 70 | 71 | describe '.new' do 72 | let(:engine) { YggdrasilEngine.new } 73 | context 'when there are problems fetching toggles' do 74 | before do 75 | backup_file = Unleash.configuration.backup_file 76 | 77 | toggles = { 78 | version: 2, 79 | features: [ 80 | { 81 | name: "Feature.A", 82 | description: "Enabled toggle", 83 | enabled: true, 84 | strategies: [{ 85 | "name": "default" 86 | }] 87 | } 88 | ] 89 | } 90 | 91 | # manually create a stub cache on disk, so we can test that we read it correctly later. 92 | File.open(backup_file, "w") do |file| 93 | file.write(toggles.to_json) 94 | end 95 | 96 | WebMock.stub_request(:get, "http://toggle-fetcher-test-url/client/features").to_return(status: 500) 97 | _toggle_fetcher = described_class.new engine # we new up a new toggle fetcher so that engine is synced 98 | end 99 | 100 | it 'reads the backup file for values' do 101 | enabled = engine.enabled?('Feature.A', {}) 102 | expect(enabled).to eq(true) 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /spec/unleash_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Unleash do 2 | it "has a version number" do 3 | expect(Unleash::VERSION).not_to be nil 4 | end 5 | 6 | it "does something useful" do 7 | expect(false).to eq(false) 8 | end 9 | 10 | context 'when configured' do 11 | before do 12 | Unleash.configure do |config| 13 | config.app_name = 'rspec_test' 14 | config.url = 'http://testurl/' 15 | end 16 | end 17 | 18 | it 'has configuration' do 19 | expect(described_class.configuration).to be_instance_of(Unleash::Configuration) 20 | end 21 | 22 | it 'proxies strategies to config' do 23 | expect(described_class.strategies).to eq(Unleash.configuration.strategies) 24 | end 25 | end 26 | 27 | it "should mount custom strategies correctly" do 28 | class TestStrategy 29 | def name 30 | 'customStrategy' 31 | end 32 | 33 | def enabled?(params, _context) 34 | params["gerkhins"] == "yes" 35 | end 36 | end 37 | 38 | Unleash.configure do |config| 39 | config.app_name = 'rspec_test' 40 | config.strategies.add(TestStrategy.new("customStrategy")) 41 | end 42 | 43 | expect(Unleash.configuration.strategies.includes?('customStrategy')).to eq(true) 44 | expect(Unleash.configuration.strategies.includes?('nonExistingStrategy')).to eq(false) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /unleash-client.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('lib', __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'unleash/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "unleash" 7 | spec.version = Unleash::VERSION 8 | spec.authors = ["Renato Arruda"] 9 | spec.email = ["rarruda@rarruda.org"] 10 | spec.licenses = ["Apache-2.0"] 11 | 12 | spec.summary = "Unleash feature toggle client." 13 | spec.description = "This is the ruby client for Unleash, a powerful feature toggle system 14 | that gives you a great overview over all feature toggles across all your applications and services." 15 | 16 | spec.homepage = "https://github.com/unleash/unleash-client-ruby" 17 | 18 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 19 | f.match(%r{^(test|spec|features)/}) 20 | end 21 | spec.bindir = 'bin' 22 | spec.executables = spec.files.grep(%r{^bin/unleash}) { |f| File.basename(f) } 23 | spec.require_paths = ["lib"] 24 | spec.required_ruby_version = ">= 2.7" 25 | 26 | spec.add_dependency "yggdrasil-engine", "~> 1.0.4" 27 | 28 | spec.add_dependency "base64", "~> 0.2.0" 29 | spec.add_dependency "logger", "~> 1.6" 30 | 31 | spec.add_development_dependency "bundler", "~> 2.1" 32 | spec.add_development_dependency "rake", "~> 12.3" 33 | spec.add_development_dependency "rspec", "~> 3.12" 34 | spec.add_development_dependency "rspec-json_expectations", "~> 2.2" 35 | spec.add_development_dependency "webmock", "~> 3.18.1" 36 | 37 | # rubocop:disable Gemspec/RubyVersionGlobalsUsage, Style/IfUnlessModifier 38 | if Gem::Version.new(RUBY_VERSION) > Gem::Version.new('3.0') 39 | spec.add_development_dependency "rubocop", "~> 1.75" 40 | end 41 | # rubocop:enable Gemspec/RubyVersionGlobalsUsage, Style/IfUnlessModifier 42 | 43 | spec.add_development_dependency "simplecov", "~> 0.21.2" 44 | spec.add_development_dependency "simplecov-lcov", "~> 0.8.0" 45 | end 46 | -------------------------------------------------------------------------------- /v6_MIGRATION_GUIDE.md: -------------------------------------------------------------------------------- 1 | # Migrating to Unleash-Client-Ruby 6.0.0 2 | 3 | This guide highlights the key changes you should be aware of when upgrading to v6.0.0 of the Unleash client. 4 | 5 | ## Custom strategy changes 6 | 7 | In version 6+, custom strategies cannot override the built-in strategies. Specifically, strategies `applicationHostname`, `default`, `flexibleRollout`, `gradualRolloutRandom`, `gradualRolloutSessionId`, `gradualRolloutUserId`, `remoteAddress` or `userWithId` throw an error on startup. Previously, creating a custom strategy would only generate a warning in the logs. 8 | 9 | The deprecated `register_custom_strategies` method has been removed. You can continue to [register custom strategies](./README.md#custom-strategies) using configuration. 10 | 11 | ## Direct access to strategy objects 12 | 13 | **Note:** If you're not using the method `known_strategies` this section doesn't affect you 14 | 15 | The objects for base strategies are no longer directly accessible via the SDK. The `known_strategies` method only returns custom strategies registered by the user. To check if a custom strategy will override either a built-in or custom strategy, use the `includes?` method (returns false if the name is available). 16 | 17 | It is strongly discouraged to access or modify any properties of the built-in strategies other than the name. In version 6+, this is a hard requirement. 18 | 19 | ## ARM requirements 20 | 21 | Version 6.0.0 introduces a new dependency on a native binary. Currently, only ARM binaries for macOS are distributed. If you require ARM support for Linux or Windows, please open a GitHub issue. --------------------------------------------------------------------------------