├── .credo.exs ├── .formatter.exs ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── NAME.md ├── README.md ├── USAGE.md ├── lib ├── mix │ └── tasks │ │ ├── shapt.ex │ │ ├── shapt.expired.ex │ │ └── shapt.template.ex ├── shapt.ex └── shapt │ ├── adapter.ex │ ├── adapters │ └── env.ex │ ├── helpers.ex │ ├── plug.ex │ └── worker.ex ├── mix.exs ├── mix.lock └── test ├── shapt ├── adapters │ └── env_test.exs ├── helpers_test.exs ├── plug_test.exs └── worker_test.exs ├── shapt_test.exs ├── support ├── test_shapt.ex └── testenv └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any config using `mix credo -C `. If no config name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: ["lib/"], 25 | excluded: [~r"/test/", ~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 26 | }, 27 | # 28 | # Load and configure plugins here: 29 | # 30 | plugins: [], 31 | # 32 | # If you create your own checks, you must specify the source files for 33 | # them here, so they can be loaded by Credo before running the analysis. 34 | # 35 | requires: [], 36 | # 37 | # If you want to enforce a style guide and need a more traditional linting 38 | # experience, you can change `strict` to `true` below: 39 | # 40 | strict: false, 41 | # 42 | # To modify the timeout for parsing files, change this value: 43 | # 44 | parse_timeout: 5000, 45 | # 46 | # If you want to use uncolored output by default, you can change `color` 47 | # to `false` below: 48 | # 49 | color: true, 50 | # 51 | # You can customize the parameters of any check by adding a second element 52 | # to the tuple. 53 | # 54 | # To disable a check put `false` as second element: 55 | # 56 | # {Credo.Check.Design.DuplicatedCode, false} 57 | # 58 | checks: [ 59 | # 60 | ## Consistency Checks 61 | # 62 | {Credo.Check.Consistency.ExceptionNames, []}, 63 | {Credo.Check.Consistency.LineEndings, []}, 64 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 65 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 66 | {Credo.Check.Consistency.SpaceInParentheses, []}, 67 | {Credo.Check.Consistency.TabsOrSpaces, []}, 68 | 69 | # 70 | ## Design Checks 71 | # 72 | # You can customize the priority of any check 73 | # Priority values are: `low, normal, high, higher` 74 | # 75 | {Credo.Check.Design.AliasUsage, 76 | [priority: :low, if_nested_deeper_than: 5, if_called_more_often_than: 0]}, 77 | # You can also customize the exit_status of each check. 78 | # If you don't want TODO comments to cause `mix credo` to fail, just 79 | # set this value to 0 (zero). 80 | # 81 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 82 | {Credo.Check.Design.TagFIXME, []}, 83 | 84 | # 85 | ## Readability Checks 86 | # 87 | {Credo.Check.Readability.AliasOrder, []}, 88 | {Credo.Check.Readability.FunctionNames, []}, 89 | {Credo.Check.Readability.LargeNumbers, []}, 90 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 91 | {Credo.Check.Readability.ModuleAttributeNames, []}, 92 | {Credo.Check.Readability.ModuleDoc, []}, 93 | {Credo.Check.Readability.ModuleNames, []}, 94 | {Credo.Check.Readability.ParenthesesInCondition, []}, 95 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 96 | {Credo.Check.Readability.PredicateFunctionNames, []}, 97 | {Credo.Check.Readability.PreferImplicitTry, []}, 98 | {Credo.Check.Readability.RedundantBlankLines, []}, 99 | {Credo.Check.Readability.Semicolons, []}, 100 | {Credo.Check.Readability.SpaceAfterCommas, []}, 101 | {Credo.Check.Readability.StringSigils, []}, 102 | {Credo.Check.Readability.TrailingBlankLine, []}, 103 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 104 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 105 | {Credo.Check.Readability.VariableNames, []}, 106 | 107 | # 108 | ## Refactoring Opportunities 109 | # 110 | {Credo.Check.Refactor.CondStatements, []}, 111 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 112 | {Credo.Check.Refactor.FunctionArity, []}, 113 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 114 | # {Credo.Check.Refactor.MapInto, []}, 115 | {Credo.Check.Refactor.MatchInCondition, []}, 116 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 117 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 118 | {Credo.Check.Refactor.Nesting, []}, 119 | {Credo.Check.Refactor.UnlessWithElse, []}, 120 | {Credo.Check.Refactor.WithClauses, []}, 121 | 122 | # 123 | ## Warnings 124 | # 125 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 126 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 127 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 128 | {Credo.Check.Warning.IExPry, []}, 129 | {Credo.Check.Warning.IoInspect, []}, 130 | # {Credo.Check.Warning.LazyLogging, []}, 131 | {Credo.Check.Warning.MixEnv, false}, 132 | {Credo.Check.Warning.OperationOnSameValues, []}, 133 | {Credo.Check.Warning.OperationWithConstantResult, []}, 134 | {Credo.Check.Warning.RaiseInsideRescue, []}, 135 | {Credo.Check.Warning.UnusedEnumOperation, []}, 136 | {Credo.Check.Warning.UnusedFileOperation, []}, 137 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 138 | {Credo.Check.Warning.UnusedListOperation, []}, 139 | {Credo.Check.Warning.UnusedPathOperation, []}, 140 | {Credo.Check.Warning.UnusedRegexOperation, []}, 141 | {Credo.Check.Warning.UnusedStringOperation, []}, 142 | {Credo.Check.Warning.UnusedTupleOperation, []}, 143 | {Credo.Check.Warning.UnsafeExec, []}, 144 | 145 | # 146 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) 147 | 148 | # 149 | # Controversial and experimental checks (opt-in, just replace `false` with `[]`) 150 | # 151 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, 152 | {Credo.Check.Consistency.UnusedVariableNames, []}, 153 | {Credo.Check.Design.DuplicatedCode, false}, 154 | {Credo.Check.Readability.AliasAs, []}, 155 | {Credo.Check.Readability.BlockPipe, false}, 156 | {Credo.Check.Readability.ImplTrue, []}, 157 | {Credo.Check.Readability.MultiAlias, false}, 158 | {Credo.Check.Readability.SeparateAliasRequire, false}, 159 | {Credo.Check.Readability.SinglePipe, false}, 160 | {Credo.Check.Readability.Specs, false}, 161 | {Credo.Check.Readability.StrictModuleLayout, false}, 162 | {Credo.Check.Readability.WithCustomTaggedTuple, false}, 163 | {Credo.Check.Refactor.ABCSize, []}, 164 | {Credo.Check.Refactor.AppendSingleItem, []}, 165 | {Credo.Check.Refactor.DoubleBooleanNegation, []}, 166 | {Credo.Check.Refactor.ModuleDependencies, false}, 167 | {Credo.Check.Refactor.NegatedIsNil, false}, 168 | {Credo.Check.Refactor.PipeChainStart, []}, 169 | {Credo.Check.Refactor.VariableRebinding, []}, 170 | {Credo.Check.Warning.LeakyEnvironment, false}, 171 | {Credo.Check.Warning.MapGetUnsafePass, false}, 172 | {Credo.Check.Warning.UnsafeToAtom, []} 173 | 174 | # 175 | # Custom checks can be created using `mix credo.gen.check`. 176 | # 177 | ] 178 | } 179 | ] 180 | } 181 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | shapt-*.tar 24 | 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.1.0 4 | 5 | - Restructure internals of the library 6 | - Simplify Shapt.Adapters.Env 7 | - Remove Shapt.Adapters.DotEnv in favor of using Shapt.Adapters.Env with optional config. 8 | - Always use ets table as state holder for the toggle values. 9 | 10 | ## v0.0.4 11 | 12 | - Pretty print map on GET endpoint. 13 | - Fix issue with mix tasks 14 | 15 | ## v0.0.3 16 | 17 | - Fix issue with mix tasks 18 | 19 | ## v0.0.2 20 | 21 | - Make the toggle module a worker that needs to be attached to a supervision 22 | tree or started with `start_link`. 23 | - Refactor Adapters to work with worker architeture. 24 | - Add option to have ets cache for all adapters. 25 | - Add instance_name function to be used on Consul adapters. 26 | - Add reload and all_values functions and callbacks 27 | - Add plug 28 | 29 | ## v0.0.1 30 | 31 | - Initial Release 32 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2019 Flávio Moreira Vieira 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /NAME.md: -------------------------------------------------------------------------------- 1 | # Name 2 | Just an explanation on the weird name of the library. 3 | Usually I like to give very simple and direct name to my libraries. 4 | For this one I was thinking to call it just Flags, but already exists 5 | FunWithFlags and it wouldn't be cool to have a library with a similar name that 6 | works on the same domain. Then [jolly roger](https://en.wikipedia.org/wiki/Jolly_Roger) 7 | crossed my mind as a cool name. That's where the pirate theming come up. 8 | 9 | So on the pirate theme two musics came to my mind: 10 | 11 | [![S.H.A.P.T.-Piratas do metal](https://img.youtube.com/vi/PUVFG6KASK8/0.jpg)](https://www.youtube.com/watch?v=PUVFG6KASK8) 12 | 13 | SHAPT is a brazilian comedy metal band and the music name directly translates to 14 | "Pirates of Metal". 15 | 16 | The music in the readme is a reference to this: 17 | 18 | [![You are a Pirate Limewire 10 hours](https://img.youtube.com/vi/IBH4g_ua5es/0.jpg)](https://www.youtube.com/watch?v=IBH4g_ua5es) 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shapt 2 | A helpful and simple way to use feature toggles/flippers/flags on your Elixir code. 3 | This library heavily uses macros to achieve it's goals. Please read our [usage guide](./USAGE.md). 4 | 5 | This is library is currently a work in progress, it's api is not *strongly* defined, *expect changes*. 6 | 7 | ## [Name](./NAME.md) 8 | Do what you want 'cause a pirate is free 9 | You are a pirate! 10 | 11 | Yar har, fiddle de dee 12 | Being a pirate is alright to be 13 | Do what you want 'cause a pirate is free 14 | You are a pirate! 15 | 16 | ## Features 17 | This are the list of main features(marked ones are already implemented): 18 | * [x] Configurable adapters that are simple to implement. 19 | * [x] `Shapt.Adapters.Env` and `Shapt.Adapters.DotEnv` built-in Adapters. 20 | * [x] `shapt.expired` mix task that exposes toggles that had his deadline expired. 21 | * [x] `shapt.template` mix task that generate template files for the configured adapter. 22 | * [x] Plug that provides a `GET` endpoint to inspect current state of the toggles. 23 | * [x] Plug that provides a `POST` endpoint that reload toggles value(reload feature must be provided by the Adapter). 24 | * [ ] Consul Adapters 25 | 26 | ## [Usage Guide](./USAGE.md) 27 | Read our usage guide to understand how to start using this library. 28 | 29 | ## Credits 30 | This library is inspired by several ideas from [Renan Ranelli](https://github.com/rranelli). 31 | 32 | ## [Changelog](./CHANGELOG.md) 33 | * current version v0.0.1 34 | 35 | ## [Code of Conduct](./CODE_OF_CONDUCT.md) 36 | 37 | ## License 38 | Shapt is under Apache v2.0 license. Check the [LICENSE](./LICENSE.md) file for more details. 39 | -------------------------------------------------------------------------------- /USAGE.md: -------------------------------------------------------------------------------- 1 | # Usage Guide 2 | 3 | Once `Shapt` is one of his dependencies. You gonna need to create a feature toggle module: 4 | 5 | ```elixir 6 | defmodule TestModule do 7 | use Shapt, 8 | adapter: {Shapt.Adapters.Env, []}, 9 | toggles: [ 10 | feature_x?: %{ 11 | key: "MYAPP_FEATURE_X", 12 | deadline: ~D[2019-12-31] 13 | }, 14 | feature_y?: %{ 15 | deadline: ~D[2009-12-31] 16 | } 17 | ] 18 | end 19 | ``` 20 | 21 | This module is a worker that you need to add to your supervision tree. `Shapt` 22 | will create the proper `child_spec/1` and `start_link/1` functions. 23 | All the state of the toggles are kept inside an ets table. 24 | 25 | ## Options 26 | 27 | - `adapter`: receives a keyword list of adapters by environment. 28 | - `toggles`: receives a keyword list of toggles to be configured. 29 | 30 | ## Provided functions 31 | 32 | Just by `using` Shapt you gonna have some extra functions on your feature toggle 33 | module: 34 | 35 | ### `toggle/2` 36 | 37 | Besides the key named functions you gonna have `toggle/2`. It can be used to 38 | evaluate the feature toggle and what to do when it's on or off. Some usage 39 | examples: 40 | 41 | ```elixir 42 | # just returning values 43 | TestModule.toggle(:feature_x?, on: FeatureX, off: OldFeature) 44 | 45 | # applying functions and params: 46 | TestModule.toggle(:feature_x?, on: {&Feature.x/2, params_x}, off: {&Feature.old/2, params_old}) 47 | 48 | # applying module, function_name and params 49 | TestModule.toggle(:feature_x?, on: {ModuleX, :function, [paramsx]}, off: {Module, :fuction, [params]}) 50 | ``` 51 | 52 | In those two last examples we use the `apply` function, be aware of that when 53 | using this feature. So make sure that the `params` provided are always a list which 54 | the size is the same as the provided function arity. 55 | 56 | ### `expired?/1` 57 | 58 | It receives the name of a toggle and says if it's expired or not. 59 | 60 | ### `enabled?/1` 61 | 62 | It receives the name of a toggle and says if it's enabled or not. 63 | 64 | ### `expired_toggles/0` 65 | 66 | List all expired toggles 67 | 68 | ### `template/0` 69 | 70 | Return the binary representing the template file generated by the Adapter. 71 | 72 | ### `reload/0` 73 | 74 | Reloads the ets table with the current state of toggles according to the adapter options. 75 | 76 | ## Env Adapter 77 | 78 | `Shapt.Adapters.Env` will work directly with environment variables using the `System.get_env/1` or loading it from a configuration file. 79 | -------------------------------------------------------------------------------- /lib/mix/tasks/shapt.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Shapt do 2 | use Mix.Task 3 | 4 | @shortdoc "Shows Shapt tasks help info" 5 | @moduledoc """ 6 | Prints help info for Shapt tasks. Doesn't accept parameters. 7 | """ 8 | 9 | def run(args) do 10 | case args do 11 | [] -> 12 | help() 13 | 14 | _any -> 15 | Mix.raise("Invalid arguments, expected: mix shapt") 16 | end 17 | end 18 | 19 | defp help do 20 | Mix.shell().info("Tasks to make it easier to use Shapt.") 21 | Mix.shell().info("Available tasks:\n") 22 | Mix.Tasks.Help.run(["--search", "shapt."]) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/mix/tasks/shapt.expired.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Shapt.Expired do 2 | use Mix.Task 3 | 4 | @shortdoc "Expose expired keys" 5 | @moduledoc """ 6 | This task verify if the toggles of the informed Module is expired. 7 | Parameters: 8 | - `--module` or `-m`: Accepts one or more modules as parameter to verify the expired toggles. Modules are separated by a comma. 9 | - `--strict` or `-s`: Instead of just returning the expired toggles. 10 | 11 | Example: 12 | 13 | `mix shapt.expired -m MyToggles,MyFlippers` 14 | 15 | `mix shapt.expired -sm MyToggles` 16 | """ 17 | 18 | def run(args) do 19 | {opts, _a, _b} = 20 | OptionParser.parse(args, 21 | strict: [strict: :boolean, module: :string], 22 | aliases: [s: :strict, m: :module] 23 | ) 24 | 25 | evaluate(opts[:strict], opts[:module]) 26 | end 27 | 28 | defp evaluate(true, modules) do 29 | expired = expired_toggles(modules) 30 | 31 | if Enum.any?(expired) do 32 | message = build_message(expired) 33 | 34 | Mix.raise("Expired keys:\n" <> message) 35 | end 36 | end 37 | 38 | defp evaluate(nil, modules) do 39 | expired = expired_toggles(modules) 40 | 41 | if Enum.any?(expired) do 42 | Mix.shell().info("Expired keys.") 43 | Mix.shell().info(build_message(expired)) 44 | end 45 | end 46 | 47 | defp expired_toggles(modules) do 48 | modules 49 | |> String.split(",") 50 | |> Enum.map(&String.trim/1) 51 | |> Enum.map(fn m -> 52 | Mix.Task.run("compile", [m]) 53 | m 54 | end) 55 | |> Enum.map(&Module.safe_concat([&1])) 56 | |> Enum.filter(&Code.ensure_loaded?/1) 57 | |> Enum.map(&{&1, &1.expired_toggles()}) 58 | |> Enum.reject(&reject_modules/1) 59 | end 60 | 61 | defp reject_modules({_any, []}), do: true 62 | defp reject_modules(_any), do: false 63 | 64 | defp build_message(expired) do 65 | expired 66 | |> Enum.map(&message/1) 67 | |> Enum.join("\n") 68 | end 69 | 70 | defp message({m, t}), do: "module: #{inspect(m)}, toggles: #{inspect(t)}" 71 | end 72 | -------------------------------------------------------------------------------- /lib/mix/tasks/shapt.template.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Shapt.Template do 2 | use Mix.Task 3 | 4 | @shortdoc "Output a template of a configuration file for a Module" 5 | @moduledoc """ 6 | This task output the template of a configuration for a Module. 7 | The template type is defined by the Adapter configured in the module. 8 | Parameters: 9 | - `--module` or `-m`(required): Accepts a module as parameter to output a template to it's adapter. 10 | - `--file` or `-f`: Accepts a filename as parameter to write the template to. If it's missing, will outpout to terminal. 11 | 12 | Example: 13 | 14 | `mix shapt.expired -m MyToggles` 15 | 16 | `mix shapt.expired -m MyToggles -f template.env` 17 | """ 18 | 19 | def run(args) do 20 | {opts, _, _} = 21 | OptionParser.parse(args, 22 | strict: [module: :string, file: :string], 23 | aliases: [m: :module, f: :file] 24 | ) 25 | 26 | Mix.Task.run("compile", [opts[:module]]) 27 | 28 | opts[:module] 29 | |> generate_template() 30 | |> do_template(opts[:file]) 31 | end 32 | 33 | defp do_template(template, nil) do 34 | Mix.shell().info(template) 35 | end 36 | 37 | defp do_template(template, file) do 38 | File.write(file, template) 39 | end 40 | 41 | defp generate_template(module) do 42 | module = 43 | module 44 | |> String.trim() 45 | |> List.wrap() 46 | |> Module.concat() 47 | 48 | if Code.ensure_loaded?(module) do 49 | module.template() 50 | else 51 | Mix.raise("#{module} is not available.") 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/shapt.ex: -------------------------------------------------------------------------------- 1 | defmodule Shapt do 2 | @moduledoc """ 3 | Use this to create your own feature toggle worker as in the example: 4 | 5 | ```elixir 6 | defmodule TestModule do 7 | use Shapt, 8 | adapter: {Shapt.Adapters.Env, []}, 9 | toggles: [ 10 | feature_x?: %{ 11 | key: "MYAPP_FEATURE_X", 12 | deadline: ~D[2019-12-31] 13 | }, 14 | feature_y?: %{ 15 | deadline: ~D[2009-12-31] 16 | } 17 | ] 18 | end 19 | ``` 20 | """ 21 | 22 | @typedoc """ 23 | Options to be passed when using `Shapt`. 24 | It's a keywordlist with the required keys `:adapter` and `:toggles`. 25 | """ 26 | @type use_opts :: [adapter: {adapter(), adapter_opts()}, toggles: toggles()] 27 | 28 | @typedoc """ 29 | A module that implements the `Shapt.Adapter` behaviour. 30 | """ 31 | @type adapter :: module() 32 | 33 | @typedoc """ 34 | Options to configure the adapter. 35 | Check the adapter documentation for more details. 36 | """ 37 | @type adapter_opts :: keyword() 38 | 39 | @typedoc """ 40 | A keywordlist with the toggles names and its configuration. 41 | """ 42 | @type toggles :: [{toggle_name(), toggle_opts()}] 43 | 44 | @typedoc """ 45 | The name of a toggle. 46 | This name gonna become a function on your module and gonna be name used to identify this toggle on all Shapt mix tasks. 47 | """ 48 | @type toggle_name :: atom() 49 | 50 | @typedoc """ 51 | It's a map with options to configure the individual toggle. 52 | The only option that Shapt defines is the `:deadline`. 53 | More options can be defined and used by the adapter. 54 | """ 55 | @type toggle_opts :: %{deadline: deadline()} 56 | 57 | @typedoc """ 58 | Defines a deadline for using the toggle. 59 | It's used by `Mix.Tasks.Shapt.Expired` task and the functions `expired?/1` and `expired_toggles/0`. 60 | """ 61 | @type deadline :: Date.t() 62 | 63 | @doc false 64 | defmacro __using__(options) do 65 | {adapter, adapter_conf} = options[:adapter] 66 | toggle_conf = options[:toggles] 67 | toggles = Enum.map(toggle_conf, &elem(&1, 0)) 68 | 69 | [ 70 | quote do 71 | def child_spec([]) do 72 | opts = [ 73 | toggles: unquote(toggle_conf), 74 | adapter: {unquote(adapter), unquote(adapter_conf)}, 75 | module: __MODULE__ 76 | ] 77 | 78 | Shapt.Worker.child_spec(opts) 79 | end 80 | 81 | def child_spec(params) do 82 | opts = [ 83 | toggles: unquote(toggle_conf), 84 | adapter: params[:adapter], 85 | module: __MODULE__ 86 | ] 87 | 88 | Shapt.Worker.child_spec(opts) 89 | end 90 | 91 | def start_link([]) do 92 | opts = [ 93 | toggle_conf: unquote(toggle_conf), 94 | adapter: unquote(adapter), 95 | adapter_conf: unquote(adapter_conf), 96 | name: __MODULE__ 97 | ] 98 | 99 | Shapt.Worker.start_link(opts) 100 | end 101 | 102 | def start_link(params) do 103 | {adapter, adapter_conf} = params[:adapter] 104 | 105 | opts = [ 106 | toggle_conf: unquote(toggle_conf), 107 | adapter: adapter, 108 | adapter_conf: adapter_conf, 109 | name: __MODULE__ 110 | ] 111 | 112 | Shapt.Worker.start_link(opts) 113 | end 114 | 115 | def all_values, do: Shapt.Worker.all_values(__MODULE__) 116 | 117 | def reload, do: Shapt.Worker.reload(__MODULE__) 118 | 119 | def enabled?(toggle) do 120 | if toggle in unquote(toggles) do 121 | Shapt.Worker.enabled?(__MODULE__, toggle) 122 | else 123 | :error 124 | end 125 | end 126 | 127 | def expired?(toggle) do 128 | Shapt.Helpers.do_expired(toggle, unquote(toggle_conf)) 129 | end 130 | 131 | def expired_toggles do 132 | Shapt.Helpers.do_all_expired(unquote(toggle_conf)) 133 | end 134 | 135 | def template do 136 | Shapt.Helpers.do_template(unquote(adapter), unquote(adapter_conf), unquote(toggle_conf)) 137 | end 138 | 139 | def toggle(name, opts) do 140 | if name in unquote(toggles) do 141 | name 142 | |> enabled?() 143 | |> Shapt.Helpers.apply_toggle(opts) 144 | else 145 | :error 146 | end 147 | end 148 | end 149 | | Enum.map(options[:toggles], fn {name, _opts} -> 150 | quote do 151 | def unquote(name)(), do: enabled?(unquote(name)) 152 | end 153 | end) 154 | ] 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /lib/shapt/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Shapt.Adapter do 2 | @moduledoc """ 3 | A behaviour that defines the basic callbacks that a Shapt Adapter needs to implement. 4 | This callbacks are just the very basic behavior that an adapter might have. 5 | """ 6 | 7 | @typedoc """ 8 | A map containing all `t:Shapt.toggle_name/0` with the value being the current state of the toggle. 9 | """ 10 | @type loaded_toggles :: %{Shapt.toggle_name() => boolean()} 11 | 12 | @doc """ 13 | Invoked at the start of the worker and everytime a reload takes place to populate the current state of the toggles. 14 | It should always return a map with all `t:Shapt.toggle_name/0`. 15 | """ 16 | @callback load(Shapt.adapter_opts(), Shapt.toggles()) :: loaded_toggles() 17 | 18 | @doc """ 19 | Invoked by the task that generates the template for the adapter. 20 | It should return a string that can be used as a template, in case a template is not applyable for the adapter returns an empty string. 21 | """ 22 | @callback create_template(Shapt.adapter_opts(), Shapt.toggles()) :: String.t() 23 | 24 | @doc """ 25 | Invoked at the `Shapt.Worker.init/1` callback of the worker. 26 | Returning `:ok` means the configuration is valid and the adapter will be able to load the toggle state for the given configuration. 27 | """ 28 | @callback validate_configuration(Shapt.adapter_opts()) :: :ok | String.t() 29 | end 30 | -------------------------------------------------------------------------------- /lib/shapt/adapters/env.ex: -------------------------------------------------------------------------------- 1 | defmodule Shapt.Adapters.Env do 2 | @behaviour Shapt.Adapter 3 | @moduledoc """ 4 | An adapter to load toggle state from environment variables or an env file. 5 | """ 6 | 7 | @typedoc """ 8 | Additional option to configure the toggle. 9 | #{__MODULE__} only defines one additional option that is `:key`. 10 | The key is the name of the environment variable to get the toggle state. 11 | 12 | If there is no `:key` set for a toggle, the adapter gonna abstract an environment variable from the `Shapt.toggle_name()`. 13 | The environment variable for that case gonna be the `t:Shapt.toggle_name/0` upcased and stripped from a question mark, if there is any. 14 | """ 15 | @type toggle_opts :: %{key: String.t()} 16 | 17 | @typedoc """ 18 | Configuration for this adapter. 19 | - `from`: source of the state of the toggles. 20 | The only options are `:file` and `:env`. 21 | If the `from` is set to `:file`, the option `file` is required. 22 | 23 | - `file`: Required when `from` is set to `:file`. 24 | It gonna be envinroment variable file used to load the toggles state. 25 | """ 26 | @type adapter_opts :: [from: :file | :env, file: filename()] 27 | 28 | @typedoc """ 29 | Path to a file that must exist when starting the Shapt worker. 30 | The content of the file must be a pair `ENVVAR=true` per line. 31 | This gonna be loaded and used as the state of the toggles. 32 | """ 33 | @type filename :: Path.t() 34 | 35 | @impl Shapt.Adapter 36 | def load(opts, toggles) do 37 | case opts[:from] do 38 | :file -> from_file(opts[:file], toggles) 39 | _from -> from_env(toggles) 40 | end 41 | end 42 | 43 | @impl Shapt.Adapter 44 | def create_template(_opts, toggles) do 45 | toggles 46 | |> Enum.map(&get_key/1) 47 | |> Enum.map(&"#{&1}=false") 48 | |> Enum.join("\n") 49 | end 50 | 51 | @impl Shapt.Adapter 52 | def validate_configuration(opts) do 53 | case opts[:from] do 54 | :file -> 55 | if File.regular?(opts[:file] || "") do 56 | :ok 57 | else 58 | "not a file" 59 | end 60 | 61 | _from -> 62 | :ok 63 | end 64 | end 65 | 66 | defp from_env(toggles) do 67 | toggles 68 | |> Enum.map(&env_toggle/1) 69 | |> Enum.into(%{}) 70 | end 71 | 72 | defp env_toggle(toggle) do 73 | value = 74 | toggle 75 | |> get_key() 76 | |> System.get_env() 77 | |> to_boolean() 78 | 79 | {elem(toggle, 0), value} 80 | end 81 | 82 | defp from_file(nil, _toggles), do: %{} 83 | 84 | defp from_file(file, toggles) do 85 | keys = Enum.map(toggles, &get_key/1) 86 | key_toggles = Enum.map(toggles, &remap_keys/1) 87 | values = load_file(file, keys) 88 | 89 | key_toggles 90 | |> Enum.map(fn {k, t} -> {t, values[k] |> to_boolean()} end) 91 | |> Enum.into(%{}) 92 | end 93 | 94 | defp load_file(file, keys) do 95 | case File.read(file) do 96 | {:error, _err} -> 97 | [] 98 | 99 | {:ok, content} -> 100 | content 101 | |> String.split("\n") 102 | |> Enum.map(&String.split(&1, "=")) 103 | |> Enum.map(&List.to_tuple/1) 104 | |> Enum.filter(&(elem(&1, 0) in keys)) 105 | |> Enum.into(%{}) 106 | end 107 | end 108 | 109 | defp remap_keys(toggle), do: {get_key(toggle), elem(toggle, 0)} 110 | 111 | defp get_key({toggle, opts}), do: opts[:key] || toggle_key(toggle) 112 | 113 | defp toggle_key(key) do 114 | key 115 | |> Atom.to_string() 116 | |> String.replace("?", "") 117 | |> String.upcase() 118 | end 119 | 120 | defp to_boolean("true"), do: true 121 | defp to_boolean(_env), do: false 122 | end 123 | -------------------------------------------------------------------------------- /lib/shapt/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Shapt.Helpers do 2 | @moduledoc false 3 | 4 | @spec do_expired(atom(), keyword()) :: boolean() 5 | def do_expired(toggle, toggle_conf) do 6 | is_expired?(toggle_conf[toggle]) 7 | end 8 | 9 | @spec do_all_expired(keyword()) :: list(atom()) 10 | def do_all_expired(toggle_conf) do 11 | toggle_conf 12 | |> Enum.filter(&is_expired?/1) 13 | |> Enum.map(fn {name, _opts} -> name end) 14 | end 15 | 16 | defp is_expired?({_name, toggle_opts}), do: is_expired?(toggle_opts) 17 | defp is_expired?(toggle_opts), do: date_compare(toggle_opts[:deadline], Date.utc_today()) 18 | 19 | @spec do_template(module(), keyword(), keyword()) :: String.t() 20 | def do_template(adapter, adapter_opts, toggle_conf) do 21 | adapter.create_template(adapter_opts, toggle_conf) 22 | end 23 | 24 | @spec apply_toggle(boolean(), keyword()) :: term() 25 | def apply_toggle(true, opts), do: apply_toggle(opts[:on]) 26 | def apply_toggle(false, opts), do: apply_toggle(opts[:off]) 27 | 28 | defp apply_toggle({f, args}) when is_function(f), do: apply(f, args) 29 | defp apply_toggle({m, f, args}), do: apply(m, f, args) 30 | defp apply_toggle(term), do: term 31 | 32 | defp date_compare(nil, _today), do: false 33 | defp date_compare(deadline, today), do: Date.compare(deadline, today) != :gt 34 | end 35 | -------------------------------------------------------------------------------- /lib/shapt/plug.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Plug) do 2 | defmodule Shapt.Plug do 3 | @moduledoc """ 4 | This plug provides two endpoints: 5 | - GET that will that will return the current value of your toggles on runtime. 6 | - POST that will reload the current value of your toggles on runtime. 7 | ``` 8 | plug Shapt.Plug, 9 | path: "/toggles", 10 | modules: [TestModule] 11 | ``` 12 | """ 13 | use Plug.Router 14 | 15 | plug(:match) 16 | plug(:dispatch, builder_opts()) 17 | 18 | get _ do 19 | with true <- conn.request_path == opts[:path], 20 | true <- Enum.all?(opts[:modules], &Code.ensure_loaded?/1), 21 | true <- Enum.all?(opts[:modules], &(&1 |> Process.whereis() |> is_pid())) do 22 | opts[:modules] 23 | |> Enum.map(&{&1, &1.all_values()}) 24 | |> prepare_response(conn, 200, opts[:formatter]) 25 | else 26 | _any -> 27 | conn 28 | end 29 | end 30 | 31 | post _ do 32 | with true <- conn.request_path == opts[:path], 33 | true <- Enum.all?(opts[:modules], &Code.ensure_loaded?/1) do 34 | opts[:modules] 35 | |> Enum.map(& &1.reload()) 36 | 37 | opts[:modules] 38 | |> Enum.map(&{&1, &1.all_values()}) 39 | |> prepare_response(conn, 201, opts[:formatter]) 40 | else 41 | _any -> 42 | conn 43 | end 44 | end 45 | 46 | match _ do 47 | conn 48 | end 49 | 50 | defp halt_with_response(conn, type, status, body) do 51 | conn 52 | |> halt 53 | |> put_resp_content_type(type) 54 | |> send_resp(status, body) 55 | end 56 | 57 | defp prepare_response(modules, conn, status, Jason) do 58 | body = format_jason(modules) 59 | halt_with_response(conn, "application/json", status, body) 60 | end 61 | 62 | defp prepare_response(modules, conn, status, Poison) do 63 | body = format_poison(modules) 64 | halt_with_response(conn, "application/json", status, body) 65 | end 66 | 67 | defp prepare_response(modules, conn, status, _formatter) do 68 | body = format_text(modules) 69 | halt_with_response(conn, "text/plain", status, body) 70 | end 71 | 72 | defp format_text(modules) do 73 | modules 74 | |> Enum.map(&format_string/1) 75 | |> Enum.join("\n") 76 | end 77 | 78 | defp format_string({mod, keys}) do 79 | "#{inspect(mod)}: #{inspect(keys, pretty: true, width: 20)}" 80 | end 81 | 82 | defp format_jason(modules) do 83 | modules 84 | |> Enum.map(fn {k, v} -> {inspect(k), v} end) 85 | |> Enum.into(%{}) 86 | |> Jason.encode!(escape: :html_safe, pretty: true) 87 | end 88 | 89 | defp format_poison(modules) do 90 | modules 91 | |> Enum.map(fn {k, v} -> {inspect(k), v} end) 92 | |> Enum.into(%{}) 93 | |> Poison.encode!() 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/shapt/worker.ex: -------------------------------------------------------------------------------- 1 | defmodule Shapt.Worker do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | @spec child_spec(keyword()) :: map() 7 | def child_spec(conf) do 8 | module = conf[:module] 9 | {adapter, adapter_conf} = conf[:adapter] 10 | 11 | opts = [ 12 | toggle_conf: conf[:toggles], 13 | adapter: adapter, 14 | adapter_conf: adapter_conf, 15 | name: module 16 | ] 17 | 18 | %{ 19 | id: module, 20 | start: {__MODULE__, :start_link, [opts]}, 21 | restart: :permanent, 22 | shutdown: 5000, 23 | type: :worker 24 | } 25 | end 26 | 27 | @spec start_link(keyword()) :: {:ok, pid()} | {:error, term()} 28 | def start_link(opts) do 29 | GenServer.start_link(__MODULE__, opts, name: opts[:name]) 30 | end 31 | 32 | @spec all_values(term()) :: map() 33 | def all_values(worker) do 34 | GenServer.call(worker, :all_values) 35 | end 36 | 37 | @spec enabled?(term(), atom()) :: boolean() 38 | def enabled?(worker, toggle) do 39 | GenServer.call(worker, {:enabled, toggle}) 40 | end 41 | 42 | @spec reload(term()) :: :ok 43 | def reload(worker) do 44 | GenServer.call(worker, :reload) 45 | end 46 | 47 | @impl GenServer 48 | def init(opts) do 49 | with :ok <- adapter?(opts[:adapter]), 50 | :ok <- opts[:adapter].validate_configuration(opts[:adapter_conf]) do 51 | {:ok, nil, {:continue, opts}} 52 | else 53 | error -> 54 | {:error, {opts[:adapter], error}} 55 | end 56 | end 57 | 58 | @impl GenServer 59 | def handle_continue(opts, nil) do 60 | table = :ets.new(:shapt, [:set, :private]) 61 | adapter = opts[:adapter] 62 | adapter_conf = opts[:adapter_conf] 63 | toggles_conf = opts[:toggle_conf] 64 | toggles = Enum.map(toggles_conf, &elem(&1, 0)) 65 | 66 | state = %{ 67 | table: table, 68 | adapter: adapter, 69 | adapter_conf: adapter_conf, 70 | toggles_conf: toggles_conf, 71 | toggles: toggles 72 | } 73 | 74 | do_reload(state) 75 | 76 | {:noreply, state} 77 | end 78 | 79 | @impl GenServer 80 | def handle_call(:all_values, _from, state) do 81 | response = 82 | state[:table] 83 | |> :ets.tab2list() 84 | |> Enum.into(%{}) 85 | 86 | {:reply, response, state} 87 | end 88 | 89 | @impl GenServer 90 | def handle_call({:enabled, toggle}, _from, state) do 91 | response = 92 | with true <- toggle in state[:toggles], 93 | [{_toggle, value}] <- :ets.lookup(state[:table], toggle) do 94 | value 95 | else 96 | _any -> nil 97 | end 98 | 99 | {:reply, response, state} 100 | end 101 | 102 | @impl GenServer 103 | def handle_call(:reload, _from, state) do 104 | do_reload(state) 105 | {:reply, :ok, state} 106 | end 107 | 108 | defp do_reload(state) do 109 | state[:adapter_conf] 110 | |> state[:adapter].load(state[:toggles_conf]) 111 | |> Enum.each(&:ets.insert(state[:table], &1)) 112 | end 113 | 114 | defp adapter?(adapter) do 115 | if Code.ensure_loaded?(adapter) and Shapt.Adapter in adapter.__info__(:attributes)[:behaviour] do 116 | :ok 117 | else 118 | "not an adapter" 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Shapt.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.1.0" 5 | def project do 6 | [ 7 | app: :shapt, 8 | version: @version, 9 | elixir: "~> 1.9", 10 | description: "An elixir feature toggle | flag | flipper library to make Blackbeard envy", 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps(), 13 | elixirc_paths: compiler_paths(Mix.env()), 14 | package: package() 15 | ] ++ 16 | docs() 17 | end 18 | 19 | def application do 20 | [ 21 | extra_applications: [:logger] 22 | ] 23 | end 24 | 25 | defp deps do 26 | [ 27 | {:plug, "~> 1.8.3", optional: true}, 28 | {:jason, "~> 1.2.2", optional: true}, 29 | {:poison, "~> 4.0.1", optional: true}, 30 | {:credo, "~> 1.5.5", only: [:dev, :test], runtime: false}, 31 | {:ex_doc, "~> 0.24.2", only: :dev, runtime: false} 32 | ] 33 | end 34 | 35 | defp docs do 36 | [ 37 | name: "Shapt", 38 | source_ref: "v#{@version}", 39 | source_url: "https://github.com/fcevado/shapt", 40 | docs: [ 41 | main: "usage", 42 | extras: ["USAGE.md", "CHANGELOG.md"] 43 | ] 44 | ] 45 | end 46 | 47 | def package do 48 | [ 49 | licenses: ["Apache 2.0"], 50 | mainteiners: ["Flávio Moreira Vieira"], 51 | files: ["mix.exs", "lib", "LICENSE.md"], 52 | links: %{ 53 | "Github" => "https://github.com/fcevado/shapt" 54 | } 55 | ] 56 | end 57 | 58 | defp compiler_paths(:test), do: ["lib", "test/support"] 59 | defp compiler_paths(_env), do: ["lib"] 60 | end 61 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 3 | "credo": {:hex, :credo, "1.5.5", "e8f422026f553bc3bebb81c8e8bf1932f498ca03339856c7fec63d3faac8424b", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dd8623ab7091956a855dc9f3062486add9c52d310dfd62748779c4315d8247de"}, 4 | "earmark": {:hex, :earmark, "1.3.5", "0db71c8290b5bc81cb0101a2a507a76dca659513984d683119ee722828b424f6", [:mix], [], "hexpm", "762b999fd414fb41e297944228aa1de2cd4a3876a07f968c8b11d1e9a2190d07"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.12", "b245e875ec0a311a342320da0551da407d9d2b65d98f7a9597ae078615af3449", [:mix], [], "hexpm", "711e2cc4d64abb7d566d43f54b78f7dc129308a63bc103fbd88550d2174b3160"}, 6 | "ex_doc": {:hex, :ex_doc, "0.24.2", "e4c26603830c1a2286dae45f4412a4d1980e1e89dc779fcd0181ed1d5a05c8d9", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "e134e1d9e821b8d9e4244687fb2ace58d479b67b282de5158333b0d57c6fb7da"}, 7 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 8 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 9 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 10 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, 11 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 12 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"}, 13 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 14 | "plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "164baaeb382d19beee0ec484492aa82a9c8685770aee33b24ec727a0971b34d0"}, 15 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm", "73c1682f0e414cfb5d9b95c8e8cd6ffcfdae699e3b05e1db744e58b7be857759"}, 16 | "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"}, 17 | } 18 | -------------------------------------------------------------------------------- /test/shapt/adapters/env_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Shapt.Adapters.EnvTest do 2 | use ExUnit.Case, async: true 3 | alias Shapt.Adapters.Env 4 | 5 | describe "load/2 from :file" do 6 | test "wihtout file confing" do 7 | assert %{} = Env.load([from: :file], a: %{}) 8 | end 9 | 10 | test "load from file only existing keys" do 11 | assert %{a: true} == Env.load([from: :file, file: "test/support/testenv"], a: %{}) 12 | end 13 | 14 | test "load unformatted key from file" do 15 | assert %{a: true, b: false} == 16 | Env.load([from: :file, file: "test/support/testenv"], a: %{}, b: %{}) 17 | end 18 | 19 | test "default unexisting key to false" do 20 | assert %{a: false} == Env.load([from: :file, file: "test/support/testenv"], a: %{key: "C"}) 21 | end 22 | end 23 | 24 | describe "load/2 from :env" do 25 | test "with envvar set" do 26 | System.put_env("A", "true") 27 | assert %{a: true} == Env.load([], a: %{}) 28 | System.put_env("A", "") 29 | end 30 | 31 | test "without envvar set" do 32 | assert %{a: false} == Env.load([], a: %{}) 33 | end 34 | end 35 | 36 | describe "create_template/2" do 37 | test "generate template using key value" do 38 | assert "B=false" == Env.create_template([], a: %{key: "B"}) 39 | end 40 | 41 | test "generate template for simple toggle name" do 42 | assert "A=false" == Env.create_template([], a: %{}) 43 | end 44 | 45 | test "generate template for complex toggle name" do 46 | assert "A_LONG_KEY=false" == Env.create_template([], a_long_key?: %{}) 47 | end 48 | end 49 | 50 | describe "validate_configuration/1" do 51 | test "from: :file with valid file" do 52 | assert :ok == Env.validate_configuration(from: :file, file: "test/support/testenv") 53 | end 54 | 55 | test "from: :file with invalid file" do 56 | assert "not a file" == Env.validate_configuration(from: :file, file: "some/file/env") 57 | end 58 | 59 | test "from: :file with no file opts" do 60 | assert "not a file" == Env.validate_configuration(from: :file) 61 | end 62 | 63 | test "from: :env" do 64 | assert :ok == Env.validate_configuration(from: :env) 65 | end 66 | 67 | test "empty opts" do 68 | assert :ok == Env.validate_configuration([]) 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /test/shapt/helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Shapt.HelpersTest do 2 | use ExUnit.Case, async: true 3 | alias Shapt.Helpers 4 | 5 | describe "do_expired/2" do 6 | test "is expired" do 7 | assert Helpers.do_expired(:key, key: %{deadline: Date.utc_today()}) 8 | end 9 | 10 | test "isn't expired" do 11 | deadline = 12 | Date.utc_today() 13 | |> Date.add(2) 14 | 15 | refute Helpers.do_expired(:key, key: %{deadline: deadline}) 16 | end 17 | 18 | test "isn't expired without deadline" do 19 | refute Helpers.do_expired(:key, key: %{}) 20 | end 21 | end 22 | 23 | describe "do_all_expired/1" do 24 | test "returns list of expired keys" do 25 | valid = 26 | Date.utc_today() 27 | |> Date.add(2) 28 | 29 | expired = Date.utc_today() 30 | 31 | assert [:b, :c] == 32 | Helpers.do_all_expired( 33 | a: %{deadline: valid}, 34 | b: %{deadline: expired}, 35 | c: %{deadline: expired}, 36 | d: %{deadline: valid} 37 | ) 38 | end 39 | end 40 | 41 | describe "do_template/3" do 42 | test "apply template to adapter" do 43 | defmodule A do 44 | def create_template(_, _), do: "it's a template" 45 | end 46 | 47 | assert "it's a template" == Helpers.do_template(A, [], []) 48 | end 49 | end 50 | 51 | describe "apply_toggle/2 true" do 52 | test "with {function, args}" do 53 | fun = fn a -> 1 + a end 54 | assert 2 == Helpers.apply_toggle(true, on: {fun, [1]}) 55 | end 56 | 57 | test "with {module, function, args}" do 58 | defmodule A do 59 | def b(c), do: c + 1 60 | end 61 | 62 | assert 2 == Helpers.apply_toggle(true, on: {A, :b, [1]}) 63 | end 64 | 65 | test "with term" do 66 | assert 2 == Helpers.apply_toggle(true, on: 2) 67 | end 68 | end 69 | 70 | describe "apply_toggle/2 false" do 71 | test "with {function, args}" do 72 | fun = fn a -> 1 + a end 73 | assert 2 == Helpers.apply_toggle(false, off: {fun, [1]}) 74 | end 75 | 76 | test "with {module, function, args}" do 77 | defmodule A do 78 | def b(c), do: c + 1 79 | end 80 | 81 | assert 2 == Helpers.apply_toggle(false, off: {A, :b, [1]}) 82 | end 83 | 84 | test "with term" do 85 | assert 2 == Helpers.apply_toggle(false, off: 2) 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /test/shapt/plug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Shapt.PlugTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | 5 | describe "GET" do 6 | test "with no formatter" do 7 | {pid, opts} = prepare(path: "/test", modules: [TestShapt]) 8 | 9 | conn = 10 | :get 11 | |> conn("/test") 12 | |> Shapt.Plug.call(opts) 13 | 14 | assert 200 == conn.status 15 | assert :sent == conn.state 16 | assert "TestShapt: %{\n feature_x: false,\n feature_y: false\n}" == conn.resp_body 17 | 18 | GenServer.stop(pid, :normal) 19 | end 20 | 21 | test "with Jason" do 22 | {pid, opts} = prepare(path: "/test", modules: [TestShapt], formatter: Jason) 23 | 24 | conn = 25 | :get 26 | |> conn("/test") 27 | |> Shapt.Plug.call(opts) 28 | 29 | assert 200 == conn.status 30 | assert :sent == conn.state 31 | 32 | assert %{"TestShapt" => %{"feature_x" => false, "feature_y" => false}} == 33 | parse_response(conn.resp_body) 34 | 35 | GenServer.stop(pid, :normal) 36 | end 37 | 38 | test "with Poison" do 39 | {pid, opts} = prepare(path: "/test", modules: [TestShapt], formatter: Poison) 40 | 41 | conn = 42 | :get 43 | |> conn("/test") 44 | |> Shapt.Plug.call(opts) 45 | 46 | assert 200 == conn.status 47 | assert :sent == conn.state 48 | 49 | assert %{"TestShapt" => %{"feature_x" => false, "feature_y" => false}} == 50 | parse_response(conn.resp_body) 51 | 52 | GenServer.stop(pid, :normal) 53 | end 54 | end 55 | 56 | describe "POST" do 57 | test "with no formatter" do 58 | {pid, opts} = prepare(path: "/test", modules: [TestShapt]) 59 | 60 | System.put_env("A", "true") 61 | System.put_env("B", "true") 62 | 63 | conn = 64 | :post 65 | |> conn("/test") 66 | |> Shapt.Plug.call(opts) 67 | 68 | assert 201 == conn.status 69 | assert :sent == conn.state 70 | assert "TestShapt: %{\n feature_x: true,\n feature_y: true\n}" == conn.resp_body 71 | 72 | System.put_env("A", "") 73 | System.put_env("B", "") 74 | GenServer.stop(pid, :normal) 75 | end 76 | 77 | test "with Jason" do 78 | {pid, opts} = prepare(path: "/test", modules: [TestShapt], formatter: Jason) 79 | 80 | System.put_env("A", "true") 81 | System.put_env("B", "true") 82 | 83 | conn = 84 | :post 85 | |> conn("/test") 86 | |> Shapt.Plug.call(opts) 87 | 88 | assert 201 == conn.status 89 | assert :sent == conn.state 90 | 91 | assert %{"TestShapt" => %{"feature_x" => true, "feature_y" => true}} == 92 | parse_response(conn.resp_body) 93 | 94 | System.put_env("A", "") 95 | System.put_env("B", "") 96 | GenServer.stop(pid, :normal) 97 | end 98 | 99 | test "with Poison" do 100 | {pid, opts} = prepare(path: "/test", modules: [TestShapt], formatter: Poison) 101 | 102 | System.put_env("A", "true") 103 | System.put_env("B", "true") 104 | 105 | conn = 106 | :post 107 | |> conn("/test") 108 | |> Shapt.Plug.call(opts) 109 | 110 | assert 201 == conn.status 111 | assert :sent == conn.state 112 | 113 | assert %{"TestShapt" => %{"feature_x" => true, "feature_y" => true}} == 114 | parse_response(conn.resp_body) 115 | 116 | System.put_env("A", "") 117 | System.put_env("B", "") 118 | GenServer.stop(pid, :normal) 119 | end 120 | end 121 | 122 | defp prepare(opts) do 123 | {:ok, pid} = TestShapt.start_link([]) 124 | opts = Shapt.Plug.init(opts) 125 | 126 | {pid, opts} 127 | end 128 | 129 | defp parse_response(body), do: Jason.decode!(body) 130 | end 131 | -------------------------------------------------------------------------------- /test/shapt/worker_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Shapt.WorkerTest do 2 | use ExUnit.Case, async: true 3 | alias Shapt.Worker 4 | 5 | describe "init/2" do 6 | test "fails when provided adapter doesn't implement adapter behaviour" do 7 | assert {:error, {A, "not an adapter"}} == Worker.init(adapter: A) 8 | end 9 | 10 | test "fails when adapter config is invalid" do 11 | defmodule A do 12 | @behaviour Shapt.Adapter 13 | 14 | def create_template(_, _), do: "" 15 | def load(_, _), do: %{} 16 | def validate_configuration(_), do: "invalid config" 17 | end 18 | 19 | assert {:error, {A, "invalid config"}} == Worker.init(adapter: A) 20 | end 21 | 22 | test "continue when config is valid" do 23 | defmodule A do 24 | @behaviour Shapt.Adapter 25 | 26 | def create_template(_, _), do: "" 27 | def load(_, _), do: %{} 28 | def validate_configuration(_), do: :ok 29 | end 30 | 31 | assert {:ok, nil, {:continue, [adapter: A]}} == Worker.init(adapter: A) 32 | end 33 | end 34 | 35 | describe "handle_contine/2" do 36 | test "build correct state from config" do 37 | defmodule A do 38 | def load(_, _), do: %{} 39 | end 40 | 41 | assert {:noreply, %{table: _, adapter: A, adapter_conf: nil, toggles_conf: [], toggles: []}} = 42 | Worker.handle_continue([adapter: A, toggle_conf: []], nil) 43 | end 44 | 45 | test "load ets table with adapter load" do 46 | defmodule A do 47 | def load(_, _), do: %{a: true, b: false} 48 | end 49 | 50 | {:noreply, %{table: ets}} = Worker.handle_continue([adapter: A, toggle_conf: []], nil) 51 | 52 | assert %{a: true, b: false} == ets |> :ets.tab2list() |> Enum.into(%{}) 53 | end 54 | end 55 | 56 | describe "handle_call/3 for :all_values" do 57 | test "build all values from ets table" do 58 | table = :ets.new(:shapt, [:set, :private]) 59 | :ets.insert_new(table, {:a, true}) 60 | :ets.insert_new(table, {:b, false}) 61 | 62 | assert {:reply, %{a: true, b: false}, %{table: table}} == 63 | Worker.handle_call(:all_values, :ok, %{table: table}) 64 | end 65 | end 66 | 67 | describe "handle_call/3 for {:enabled, _}" do 68 | test "returns nil if provided toggle is not a toggle" do 69 | assert {:reply, nil, %{toggles: []}} == 70 | Worker.handle_call({:enabled, :some_toggle}, :ok, %{toggles: []}) 71 | end 72 | 73 | test "returns nil if toggle is not present in ets" do 74 | table = :ets.new(:shapt, [:set, :private]) 75 | 76 | assert {:reply, nil, %{toggles: [:some_toggle], table: table}} == 77 | Worker.handle_call({:enabled, :some_toggle}, :ok, %{ 78 | toggles: [:some_toggle], 79 | table: table 80 | }) 81 | end 82 | 83 | test "returns toggle value" do 84 | table = :ets.new(:shapt, [:set, :private]) 85 | :ets.insert_new(table, {:some_toggle, true}) 86 | 87 | assert {:reply, true, %{toggles: [:some_toggle], table: table}} == 88 | Worker.handle_call({:enabled, :some_toggle}, :ok, %{ 89 | toggles: [:some_toggle], 90 | table: table 91 | }) 92 | end 93 | end 94 | 95 | describe "handle_call/3 for :reload" do 96 | test "reload ets table with adapter response" do 97 | defmodule A do 98 | def load(_, _), do: %{a: true, b: false} 99 | end 100 | 101 | table = :ets.new(:shapt, [:set, :private]) 102 | :ets.insert_new(table, {:a, false}) 103 | :ets.insert_new(table, {:b, true}) 104 | state = %{table: table, adapter: A} 105 | 106 | assert {:reply, :ok, state} == Worker.handle_call(:reload, :ok, state) 107 | assert %{a: true, b: false} == table |> :ets.tab2list() |> Enum.into(%{}) 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /test/shapt_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ShaptTest do 2 | use ExUnit.Case 3 | 4 | describe "__using__ macro has been applied to module" do 5 | test "module includes all functions" do 6 | expected_functions = [ 7 | child_spec: 1, 8 | start_link: 1, 9 | all_values: 0, 10 | reload: 0, 11 | enabled?: 1, 12 | expired?: 1, 13 | expired_toggles: 0, 14 | template: 0, 15 | toggle: 2, 16 | feature_x: 0, 17 | feature_y: 0 18 | ] 19 | 20 | functions = TestShapt.__info__(:functions) 21 | 22 | assert [] == functions -- expected_functions 23 | end 24 | end 25 | 26 | describe "expired?/1" do 27 | test "delegates to helper with no worker running" do 28 | refute TestShapt.expired?(:feature_x) 29 | end 30 | end 31 | 32 | describe "expired_toggles/0" do 33 | test "delegates to helper with no worker running" do 34 | assert [] == TestShapt.expired_toggles() 35 | end 36 | end 37 | 38 | describe "template/0" do 39 | test "delegates to helper with no worker running" do 40 | assert "A=false\nB=false" == TestShapt.template() 41 | end 42 | end 43 | 44 | describe "worker tests" do 45 | test "start_link/1" do 46 | assert {:ok, pid} = TestShapt.start_link([]) 47 | GenServer.stop(pid, :normal) 48 | end 49 | 50 | test "all_values/0" do 51 | assert {:ok, pid} = TestShapt.start_link([]) 52 | assert %{feature_x: false, feature_y: false} == TestShapt.all_values() 53 | 54 | GenServer.stop(pid, :normal) 55 | end 56 | 57 | test "enabled?/1" do 58 | assert {:ok, pid} = TestShapt.start_link([]) 59 | assert false == TestShapt.enabled?(:feature_x) 60 | 61 | GenServer.stop(pid, :normal) 62 | end 63 | 64 | test "toggle/2" do 65 | assert {:ok, pid} = TestShapt.start_link([]) 66 | assert 1 == TestShapt.toggle(:feature_x, off: 1) 67 | 68 | GenServer.stop(pid, :normal) 69 | end 70 | 71 | test "feature_x/0" do 72 | assert {:ok, pid} = TestShapt.start_link([]) 73 | assert false == TestShapt.feature_x() 74 | 75 | GenServer.stop(pid, :normal) 76 | end 77 | 78 | test "feature_y/0" do 79 | assert {:ok, pid} = TestShapt.start_link([]) 80 | assert false == TestShapt.feature_y() 81 | 82 | GenServer.stop(pid, :normal) 83 | end 84 | 85 | test "reload/0" do 86 | assert {:ok, pid} = TestShapt.start_link([]) 87 | assert false == TestShapt.feature_y() 88 | 89 | System.put_env("B", "true") 90 | 91 | assert :ok == TestShapt.reload() 92 | assert true == TestShapt.feature_y() 93 | 94 | System.put_env("B", "") 95 | GenServer.stop(pid, :normal) 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /test/support/test_shapt.ex: -------------------------------------------------------------------------------- 1 | defmodule TestShapt do 2 | use Shapt, 3 | adapter: {Shapt.Adapters.Env, []}, 4 | toggles: [ 5 | feature_x: %{key: "A"}, 6 | feature_y: %{key: "B"} 7 | ] 8 | end 9 | -------------------------------------------------------------------------------- /test/support/testenv: -------------------------------------------------------------------------------- 1 | A=true 2 | B=something 3 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------