From 22e68dff7524cf08f395acc492a26e0a2a6ca560 Mon Sep 17 00:00:00 2001 From: tom Date: Fri, 15 Dec 2023 00:31:05 +0100 Subject: [PATCH 1/7] Update changelog for V2 --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c0c1f8..64fadc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v2.0 (unreleased) + +Work is in progress towards v2.0 of the package. + +Main goals: + +* [ ] Config overhaul. Stop using the config.exs file, and rather do something like what Ecto does. The host application should define its own module (like an Ecto repo) that provides the flag querying and toggling API. The package config should be provided with an init callback in the custom module. +* [ ] Start behaviour. It should never start automatically. +* [ ] Stop relying on atoms. Use binaries wherever possible. Relying on atoms makes some things harder, especially when handling user input in the UI package. + ## v1.12.0 * Add support for Elixir 1.17 and 1.16. Drop support for Elixir 1.13 and 1.14. Elixir >= 1.15 is now required. Dropping support for older versions of Elixir simply means that this package is no longer tested with them in CI, and that compatibility issues are not considered bugs. From 5c88b932eda7d8a2e94094c8414f3d00511864ad Mon Sep 17 00:00:00 2001 From: tom Date: Fri, 15 Dec 2023 00:36:42 +0100 Subject: [PATCH 2/7] Add v2.0 note at the top of the readme --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 67b2aa2..237369b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ # FunWithFlags +--- +⚠️ This is the branch for version 2.0 of the package. It's a work in progress and unstable. + +v2: [![Mix Tests](https://github.com/tompave/fun_with_flags/actions/workflows/test.yml/badge.svg?branch=v2)](https://github.com/tompave/fun_with_flags/actions/workflows/test.yml?query=branch%3Av2) +v2: [![Code Quality](https://github.com/tompave/fun_with_flags/actions/workflows/quality.yml/badge.svg?branch=v2)](https://github.com/tompave/fun_with_flags/actions/workflows/quality.yml?query=branch%3Av2) + +--- + [![Mix Tests](https://github.com/tompave/fun_with_flags/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/tompave/fun_with_flags/actions/workflows/test.yml?query=branch%3Amaster) [![Code Quality](https://github.com/tompave/fun_with_flags/actions/workflows/quality.yml/badge.svg?branch=master)](https://github.com/tompave/fun_with_flags/actions/workflows/quality.yml?query=branch%3Amaster) [![Hex.pm](https://img.shields.io/hexpm/v/fun_with_flags.svg)](https://hex.pm/packages/fun_with_flags) From 114a6bf5995a88b7bda7ed17fec80a2ce945253d Mon Sep 17 00:00:00 2001 From: tom Date: Sat, 2 Dec 2023 01:33:33 +0100 Subject: [PATCH 3/7] Add a module to be use'd to define entrypoints --- lib/fun_with_flags/entry_point.ex | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 lib/fun_with_flags/entry_point.ex diff --git a/lib/fun_with_flags/entry_point.ex b/lib/fun_with_flags/entry_point.ex new file mode 100644 index 0000000..dbfcdcf --- /dev/null +++ b/lib/fun_with_flags/entry_point.ex @@ -0,0 +1,31 @@ +defmodule FunWithFlags.EntryPoint do + @moduledoc """ + Defines an entry point to query feature flags. + + ## Example + + defmodule MyApp.Flags do + use FunWithFlags.EntryPoint + end + + MyApp.Flags.enabled?(:foo) + + """ + + @doc false + defmacro __using__(opts) do + quote bind_quoted: [opts: opts] do + @behaviour FunWithFlags.EntryPoint + + # TODO: add what's currently in the top module. + end + end + + @doc """ + An entry point must define this callback to provide its configuration. + + This function is supposed to retrieve any runtime config (e.g. ENV vars) and + return it as a keyword list. + """ + @callback config() :: {:ok, Keyword.t()} +end From fa153e410fea216f840d4fddae4bb0f44b5de114 Mon Sep 17 00:00:00 2001 From: tom Date: Fri, 16 Aug 2024 00:47:55 +0200 Subject: [PATCH 4/7] Moved the public interface functions into the entry point module --- lib/fun_with_flags.ex | 523 +---------------------------- lib/fun_with_flags/entry_point.ex | 528 +++++++++++++++++++++++++++++- 2 files changed, 532 insertions(+), 519 deletions(-) diff --git a/lib/fun_with_flags.ex b/lib/fun_with_flags.ex index 7987875..6abe65c 100644 --- a/lib/fun_with_flags.ex +++ b/lib/fun_with_flags.ex @@ -2,526 +2,15 @@ defmodule FunWithFlags do @moduledoc """ FunWithFlags, the Elixir feature flag library. - This module provides the public interface to the library and its API is - made of three simple methods to enable, disable and query feature flags. + This module is the legacy interface from v1.x. - In their simplest form, flags can be toggled on and off globally. - - More advanced rules or "gates" are available, and they can be set and queried - for any term that implements these protocols: - - * The `FunWithFlags.Actor` protocol can be - implemented for types and structs that should have specific rules. For - example, in web applications it's common to use a `%User{}` struct or - equivalent as an actor, or perhaps the current country of the request. - - * The `FunWithFlags.Group` protocol can be - implemented for types and structs that should belong to groups for which - one wants to enable and disable some flags. For example, one could implement - the protocol for a `%User{}` struct to identify administrators. - - - See the [Usage](/fun_with_flags/readme.html#usage) notes for a more detailed - explanation. - """ - - alias FunWithFlags.{Config, Flag, Gate} - - @store FunWithFlags.Config.store_module_determined_at_compile_time() - - @type options :: Keyword.t - - - @doc """ - Checks if a flag is enabled. - - It can be invoked with just the flag name, as an atom, - to check the general status of a flag (i.e. the boolean gate). - - ## Options - - * `:for` - used to provide a term for which the flag could - have a specific value. The passed term should implement the - `Actor` or `Group` protocol, or both. - - ## Examples - - This example relies on the [reference implementation](https://github.com/tompave/fun_with_flags/blob/master/test/support/test_user.ex) - used in the tests. - - iex> alias FunWithFlags.TestUser, as: User - iex> harry = %User{id: 1, name: "Harry Potter", groups: ["wizards", "gryffindor"]} - iex> FunWithFlags.disable(:elder_wand) - iex> FunWithFlags.enable(:elder_wand, for_actor: harry) - iex> FunWithFlags.enabled?(:elder_wand) - false - iex> FunWithFlags.enabled?(:elder_wand, for: harry) - true - iex> voldemort = %User{id: 7, name: "Tom Riddle", groups: ["wizards", "slytherin"]} - iex> FunWithFlags.enabled?(:elder_wand, for: voldemort) - false - iex> filch = %User{id: 88, name: "Argus Filch", groups: ["staff"]} - iex> FunWithFlags.enable(:magic_wands, for_group: "wizards") - iex> FunWithFlags.enabled?(:magic_wands, for: harry) - true - iex> FunWithFlags.enabled?(:magic_wands, for: voldemort) - true - iex> FunWithFlags.enabled?(:magic_wands, for: filch) - false - - """ - @spec enabled?(atom, options) :: boolean - def enabled?(flag_name, options \\ []) - - def enabled?(flag_name, []) when is_atom(flag_name) do - {:ok, flag} = @store.lookup(flag_name) - Flag.enabled?(flag) - end - - def enabled?(flag_name, [for: nil]) do - enabled?(flag_name) - end - - def enabled?(flag_name, [for: item]) when is_atom(flag_name) do - {:ok, flag} = @store.lookup(flag_name) - Flag.enabled?(flag, for: item) - end - - - @doc """ - Enables a feature flag. - - ## Options - - * `:for_actor` - used to enable the flag for a specific term only. - The value can be any term that implements the `Actor` protocol. - * `:for_group` - used to enable the flag for a specific group only. - The value should be a binary or an atom (It's internally converted - to a binary and it's stored and retrieved as a binary. Atoms are - supported for retro-compatibility with versions <= 0.9) - * `:for_percentage_of` - used to enable the flag for a percentage - of time or actors, expressed as `{:time, float}` or `{:actors, float}`, - where float is in the range `0.0 < x < 1.0`. - - ## Examples - - ### Enable globally - - iex> FunWithFlags.enabled?(:super_shrink_ray) - false - iex> FunWithFlags.enable(:super_shrink_ray) - {:ok, true} - iex> FunWithFlags.enabled?(:super_shrink_ray) - true - - ### Enable for an actor - - iex> FunWithFlags.disable(:warp_drive) - {:ok, false} - iex> FunWithFlags.enable(:warp_drive, for_actor: "Scotty") - {:ok, true} - iex> FunWithFlags.enabled?(:warp_drive) - false - iex> FunWithFlags.enabled?(:warp_drive, for: "Scotty") - true - - ### Enable for a group - - This example relies on the [reference implementation](https://github.com/tompave/fun_with_flags/blob/master/test/support/test_user.ex) - used in the tests. - - iex> alias FunWithFlags.TestUser, as: User - iex> marty = %User{name: "Marty McFly", groups: ["students", "time_travelers"]} - iex> doc = %User{name: "Emmet Brown", groups: ["scientists", "time_travelers"]} - iex> buford = %User{name: "Buford Tannen", groups: ["gunmen", "bandits"]} - iex> FunWithFlags.enable(:delorean, for_group: "time_travelers") - {:ok, true} - iex> FunWithFlags.enabled?(:delorean) - false - iex> FunWithFlags.enabled?(:delorean, for: buford) - false - iex> FunWithFlags.enabled?(:delorean, for: marty) - true - iex> FunWithFlags.enabled?(:delorean, for: doc) - true - - - ### Enable for a percentage of the time - - iex> FunWithFlags.disable(:random_glitch) - iex> FunWithFlags.enable(:random_glitch, for_percentage_of: {:time, 0.999999999}) - iex> FunWithFlags.enabled?(:random_glitch) - true - iex> FunWithFlags.enable(:random_glitch, for_percentage_of: {:time, 0.000000001}) - iex> FunWithFlags.enabled?(:random_glitch) - false - - ### Enable for a percentage of the actors - - This example is based on the fact that the actor score for the actor-flag pair - `marty + :new_ui` is lower than 50%, and for the `buford + :new_ui` is higher. - - iex> FunWithFlags.disable(:new_ui) - iex> FunWithFlags.enable(:new_ui, for_percentage_of: {:actors, 0.5}) - iex> FunWithFlags.enabled?(:new_ui) - false - iex> alias FunWithFlags.TestUser, as: User - iex> marty = %User{id: 42, name: "Marty McFly"} - iex> buford = %User{id: 2, name: "Buford Tannen"} - iex> FunWithFlags.enabled?(:new_ui, for: marty) - true - iex> FunWithFlags.enabled?(:new_ui, for: buford) - false - - """ - @spec enable(atom, options) :: {:ok, true} | {:error, any} - def enable(flag_name, options \\ []) - - def enable(flag_name, []) when is_atom(flag_name) do - gate = Gate.new(:boolean, true) - case @store.put(flag_name, gate) do - {:ok, flag} -> verify(flag) - error -> error - end - end - - def enable(flag_name, [for_actor: nil]) do - enable(flag_name) - end - - def enable(flag_name, [for_actor: actor]) when is_atom(flag_name) do - gate = Gate.new(:actor, actor, true) - case @store.put(flag_name, gate) do - {:ok, flag} -> verify(flag, for: actor) - error -> error - end - end - - - def enable(flag_name, [for_group: nil]) do - enable(flag_name) - end - - def enable(flag_name, [for_group: group_name]) when is_atom(flag_name) do - gate = Gate.new(:group, group_name, true) - case @store.put(flag_name, gate) do - {:ok, _flag} -> {:ok, true} - error -> error - end - end - - - def enable(flag_name, [for_percentage_of: {:time, ratio}]) when is_atom(flag_name) do - gate = Gate.new(:percentage_of_time, ratio) - case @store.put(flag_name, gate) do - {:ok, _flag} -> {:ok, true} - error -> error - end - end - - def enable(flag_name, [for_percentage_of: {:actors, ratio}]) when is_atom(flag_name) do - gate = Gate.new(:percentage_of_actors, ratio) - case @store.put(flag_name, gate) do - {:ok, _flag} -> {:ok, true} - error -> error - end - end - - - @doc """ - Disables a feature flag. - - ## Options - - * `:for_actor` - used to disable the flag for a specific term only. - The value can be any term that implements the `Actor` protocol. - * `:for_group` - used to disable the flag for a specific group only. - The value should be a binary or an atom (It's internally converted - to a binary and it's stored and retrieved as a binary. Atoms are - supported for retro-compatibility with versions <= 0.9) - * `:for_percentage_of` - used to disable the flag for a percentage - of time or actors, expressed as `{:time, float}` or `{:actors, float}`, - where float is in the range `0.0 < x < 1.0`. - - ## Examples - - ### Disable globally - - iex> FunWithFlags.enable(:random_koala_gifs) - iex> FunWithFlags.enabled?(:random_koala_gifs) - true - iex> FunWithFlags.disable(:random_koala_gifs) - {:ok, false} - iex> FunWithFlags.enabled?(:random_koala_gifs) - false - - - ## Disable for an actor - - iex> FunWithFlags.enable(:spider_sense) - {:ok, true} - iex> villain = %{name: "Venom"} - iex> FunWithFlags.disable(:spider_sense, for_actor: villain) - {:ok, false} - iex> FunWithFlags.enabled?(:spider_sense) - true - iex> FunWithFlags.enabled?(:spider_sense, for: villain) - false - - ### Disable for a group - - This example relies on the [reference implementation](https://github.com/tompave/fun_with_flags/blob/master/test/support/test_user.ex) - used in the tests. - - iex> alias FunWithFlags.TestUser, as: User - iex> harry = %User{name: "Harry Potter", groups: ["wizards", "gryffindor"]} - iex> dudley = %User{name: "Dudley Dursley", groups: ["muggles"]} - iex> FunWithFlags.enable(:hogwarts) - {:ok, true} - iex> FunWithFlags.disable(:hogwarts, for_group: "muggles") - {:ok, false} - iex> FunWithFlags.enabled?(:hogwarts) - true - iex> FunWithFlags.enabled?(:hogwarts, for: harry) - true - iex> FunWithFlags.enabled?(:hogwarts, for: dudley) - false - - ### Disable for a percentage of the time - - iex> FunWithFlags.clear(:random_glitch) - :ok - iex> FunWithFlags.disable(:random_glitch, for_percentage_of: {:time, 0.999999999}) - {:ok, false} - iex> FunWithFlags.enabled?(:random_glitch) - false - iex> FunWithFlags.disable(:random_glitch, for_percentage_of: {:time, 0.000000001}) - {:ok, false} - iex> FunWithFlags.enabled?(:random_glitch) - true - - ### Disable for a percentage of the actors - - iex> FunWithFlags.disable(:new_ui, for_percentage_of: {:actors, 0.3}) - {:ok, false} - - """ - @spec disable(atom, options) :: {:ok, boolean()} | {:error, any} - def disable(flag_name, options \\ []) - - def disable(flag_name, []) when is_atom(flag_name) do - gate = Gate.new(:boolean, false) - case @store.put(flag_name, gate) do - {:ok, flag} -> verify(flag) - error -> error - end - end - - def disable(flag_name, [for_actor: nil]) do - disable(flag_name) - end - - def disable(flag_name, [for_actor: actor]) when is_atom(flag_name) do - gate = Gate.new(:actor, actor, false) - case @store.put(flag_name, gate) do - {:ok, flag} -> verify(flag, for: actor) - error -> error - end - end - - def disable(flag_name, [for_group: nil]) do - disable(flag_name) - end - - def disable(flag_name, [for_group: group_name]) when is_atom(flag_name) do - gate = Gate.new(:group, group_name, false) - case @store.put(flag_name, gate) do - {:ok, _flag} -> {:ok, false} - error -> error - end - end - - - def disable(flag_name, [for_percentage_of: {type, ratio}]) - when is_atom(flag_name) and is_float(ratio) do - inverted_ratio = 1.0 - ratio - case enable(flag_name, [for_percentage_of: {type, inverted_ratio}]) do - {:ok, true} -> {:ok, false} - error -> error - end - end - - - @doc """ - Clears the data of a feature flag. - - Clears the data for an entire feature flag or for a specific - Actor or Group gate. Clearing a boolean gate is not supported - because a missing boolean gate is equivalent to a disabled boolean - gate. - - Sometimes enabling or disabling a gate is not what you want, and you - need to remove that gate's rules instead. For example, if you don't need - anymore to explicitly enable or disable a flag for an actor, and the - default state should be used instead, you'll want to clear the gate. - - It's also possible to clear the entire flag, by not passing any option. - - ## Options - - * `for_actor: an_actor` - used to clear the flag for a specific term only. - The value can be any term that implements the `Actor` protocol. - * `for_group: a_group_name` - used to clear the flag for a specific group only. - The value should be a binary or an atom (It's internally converted - to a binary and it's stored and retrieved as a binary. Atoms are - supported for retro-compatibility with versions <= 0.9) - * `boolean: true` - used to clear the boolean gate. - * `for_percentage: true` - used to clear any percentage gate. - - ## Examples - - iex> alias FunWithFlags.TestUser, as: User - iex> harry = %User{id: 1, name: "Harry Potter", groups: ["wizards", "gryffindor"]} - iex> hagrid = %User{id: 2, name: "Rubeus Hagrid", groups: ["wizards", "gamekeeper"]} - iex> dudley = %User{id: 3, name: "Dudley Dursley", groups: ["muggles"]} - iex> FunWithFlags.disable(:wands) - iex> FunWithFlags.enable(:wands, for_group: "wizards") - iex> FunWithFlags.disable(:wands, for_actor: hagrid) - iex> - iex> FunWithFlags.enabled?(:wands) - false - iex> FunWithFlags.enabled?(:wands, for: harry) - true - iex> FunWithFlags.enabled?(:wands, for: hagrid) - false - iex> FunWithFlags.enabled?(:wands, for: dudley) - false - iex> - iex> FunWithFlags.clear(:wands, for_actor: hagrid) - :ok - iex> FunWithFlags.enabled?(:wands, for: hagrid) - true - iex> - iex> FunWithFlags.clear(:wands) - :ok - iex> FunWithFlags.enabled?(:wands) - false - iex> FunWithFlags.enabled?(:wands, for: harry) - false - iex> FunWithFlags.enabled?(:wands, for: hagrid) - false - iex> FunWithFlags.enabled?(:wands, for: dudley) - false - - """ - @spec clear(atom, options) :: :ok | {:error, any} - def clear(flag_name, options \\ []) - - def clear(flag_name, []) when is_atom(flag_name) do - case @store.delete(flag_name) do - {:ok, _flag} -> :ok - error -> error - end - end - - def clear(flag_name, [boolean: true]) do - gate = Gate.new(:boolean, false) # we only care about the gate id - _clear_gate(flag_name, gate) - end - - def clear(flag_name, [for_actor: nil]) do - clear(flag_name) - end - - def clear(flag_name, [for_actor: actor]) when is_atom(flag_name) do - gate = Gate.new(:actor, actor, false) # we only care about the gate id - _clear_gate(flag_name, gate) - end - - def clear(flag_name, [for_group: nil]) do - clear(flag_name) - end - - def clear(flag_name, [for_group: group_name]) when is_atom(flag_name) do - gate = Gate.new(:group, group_name, false) # we only care about the gate id - _clear_gate(flag_name, gate) - end - - def clear(flag_name, [for_percentage: true]) do - gate = Gate.new(:percentage_of_time, 0.5) # we only care about the gate id - _clear_gate(flag_name, gate) - end - - defp _clear_gate(flag_name, gate) do - case @store.delete(flag_name, gate) do - {:ok, _flag} -> :ok - error -> error - end - end - - - @doc """ - Returns a list of all flag names currently configured, as atoms. - - This can be useful for debugging or for display purposes, - but it's not meant to be used at runtime. Undefined flags, - for example, will be considered disabled. + See the [Usage](/fun_with_flags/readme.html#usage). """ - @spec all_flag_names() :: {:ok, [atom]} | {:error, any} - def all_flag_names do - Config.persistence_adapter().all_flag_names() - end - @doc """ - Returns a list of all the flags currently configured, as data structures. + use FunWithFlags.EntryPoint - This function is provided for debugging and to build more complex - functionality (e.g. it's used in the web GUI), but it is not meant to be - used at runtime to check if a flag is enabled. - - To query the value of a flag, please use the `enabled?2` function instead. - """ - @spec all_flags() :: {:ok, [FunWithFlags.Flag.t]} | {:error, any} - def all_flags do - Config.persistence_adapter().all_flags() + @impl true + def config do + {:ok, []} end - - - @doc """ - Returns a `FunWithFlags.Flag` struct for the given name, or `nil` if - no flag is found. - - Useful for debugging. - """ - @spec get_flag(atom) :: FunWithFlags.Flag.t | nil | {:error, any} - def get_flag(name) do - case all_flag_names() do - {:ok, names} -> - if name in names do - case Config.persistence_adapter().get(name) do - {:ok, flag} -> flag - error -> error - end - else - nil - end - error -> error - end - end - - - defp verify(flag) do - {:ok, Flag.enabled?(flag)} - end - defp verify(flag, [for: data]) do - {:ok, Flag.enabled?(flag, for: data)} - end - - # Used in some tests and to debug. - # - # Apparently calling `Config.store_module_determined_at_compile_time` even - # just once in a test causes all sorts of weird test failures everywhere. - # - @doc false - def compiled_store, do: @store end diff --git a/lib/fun_with_flags/entry_point.ex b/lib/fun_with_flags/entry_point.ex index dbfcdcf..b767088 100644 --- a/lib/fun_with_flags/entry_point.ex +++ b/lib/fun_with_flags/entry_point.ex @@ -2,14 +2,42 @@ defmodule FunWithFlags.EntryPoint do @moduledoc """ Defines an entry point to query feature flags. + An entry point provides the public interface to the library. Its API + consists of simple functions to enable, disable and query feature flags. + + In order to define an entry point, use this module inside your custom module: + ## Example defmodule MyApp.Flags do use FunWithFlags.EntryPoint + + @impl true + def config do + {:ok, [...]} + end end - MyApp.Flags.enabled?(:foo) + {:ok, true} = MyApp.Flags.enabled?(:my_named_flag) + + + In their simplest form, flags can be toggled on and off globally. + More advanced rules or "gates" are available, and they can be set and queried + for any term that implements these protocols: + + * The `FunWithFlags.Actor` protocol can be + implemented for types and structs that should have specific rules. For + example, in web applications it's common to use a `%User{}` struct or + equivalent as an actor, or perhaps the current country of the request. + + * The `FunWithFlags.Group` protocol can be + implemented for types and structs that should belong to groups for which + one wants to enable and disable some flags. For example, one could implement + the protocol for a `%User{}` struct to identify administrators. + + See the [Usage](/fun_with_flags/readme.html#usage) notes for a more detailed + explanation. """ @doc false @@ -17,7 +45,503 @@ defmodule FunWithFlags.EntryPoint do quote bind_quoted: [opts: opts] do @behaviour FunWithFlags.EntryPoint - # TODO: add what's currently in the top module. + # TODO: add what's currently in the top module. + alias FunWithFlags.{Config, Flag, Gate} + + @store FunWithFlags.Config.store_module_determined_at_compile_time() + + @type options :: Keyword.t + + @doc """ + Checks if a flag is enabled. + + It can be invoked with just the flag name, as an atom, + to check the general status of a flag (i.e. the boolean gate). + + ## Options + + * `:for` - used to provide a term for which the flag could + have a specific value. The passed term should implement the + `Actor` or `Group` protocol, or both. + + ## Examples + + This example relies on the [reference implementation](https://github.com/tompave/fun_with_flags/blob/master/test/support/test_user.ex) + used in the tests. + + iex> alias FunWithFlags.TestUser, as: User + iex> harry = %User{id: 1, name: "Harry Potter", groups: ["wizards", "gryffindor"]} + iex> FunWithFlags.disable(:elder_wand) + iex> FunWithFlags.enable(:elder_wand, for_actor: harry) + iex> FunWithFlags.enabled?(:elder_wand) + false + iex> FunWithFlags.enabled?(:elder_wand, for: harry) + true + iex> voldemort = %User{id: 7, name: "Tom Riddle", groups: ["wizards", "slytherin"]} + iex> FunWithFlags.enabled?(:elder_wand, for: voldemort) + false + iex> filch = %User{id: 88, name: "Argus Filch", groups: ["staff"]} + iex> FunWithFlags.enable(:magic_wands, for_group: "wizards") + iex> FunWithFlags.enabled?(:magic_wands, for: harry) + true + iex> FunWithFlags.enabled?(:magic_wands, for: voldemort) + true + iex> FunWithFlags.enabled?(:magic_wands, for: filch) + false + + """ + @spec enabled?(atom, options) :: boolean + def enabled?(flag_name, options \\ []) + + def enabled?(flag_name, []) when is_atom(flag_name) do + {:ok, flag} = @store.lookup(flag_name) + Flag.enabled?(flag) + end + + def enabled?(flag_name, [for: nil]) do + enabled?(flag_name) + end + + def enabled?(flag_name, [for: item]) when is_atom(flag_name) do + {:ok, flag} = @store.lookup(flag_name) + Flag.enabled?(flag, for: item) + end + + @doc """ + Enables a feature flag. + + ## Options + + * `:for_actor` - used to enable the flag for a specific term only. + The value can be any term that implements the `Actor` protocol. + * `:for_group` - used to enable the flag for a specific group only. + The value should be a binary or an atom (It's internally converted + to a binary and it's stored and retrieved as a binary. Atoms are + supported for retro-compatibility with versions <= 0.9) + * `:for_percentage_of` - used to enable the flag for a percentage + of time or actors, expressed as `{:time, float}` or `{:actors, float}`, + where float is in the range `0.0 < x < 1.0`. + + ## Examples + + ### Enable globally + + iex> FunWithFlags.enabled?(:super_shrink_ray) + false + iex> FunWithFlags.enable(:super_shrink_ray) + {:ok, true} + iex> FunWithFlags.enabled?(:super_shrink_ray) + true + + ### Enable for an actor + + iex> FunWithFlags.disable(:warp_drive) + {:ok, false} + iex> FunWithFlags.enable(:warp_drive, for_actor: "Scotty") + {:ok, true} + iex> FunWithFlags.enabled?(:warp_drive) + false + iex> FunWithFlags.enabled?(:warp_drive, for: "Scotty") + true + + ### Enable for a group + + This example relies on the [reference implementation](https://github.com/tompave/fun_with_flags/blob/master/test/support/test_user.ex) + used in the tests. + + iex> alias FunWithFlags.TestUser, as: User + iex> marty = %User{name: "Marty McFly", groups: ["students", "time_travelers"]} + iex> doc = %User{name: "Emmet Brown", groups: ["scientists", "time_travelers"]} + iex> buford = %User{name: "Buford Tannen", groups: ["gunmen", "bandits"]} + iex> FunWithFlags.enable(:delorean, for_group: "time_travelers") + {:ok, true} + iex> FunWithFlags.enabled?(:delorean) + false + iex> FunWithFlags.enabled?(:delorean, for: buford) + false + iex> FunWithFlags.enabled?(:delorean, for: marty) + true + iex> FunWithFlags.enabled?(:delorean, for: doc) + true + + + ### Enable for a percentage of the time + + iex> FunWithFlags.disable(:random_glitch) + iex> FunWithFlags.enable(:random_glitch, for_percentage_of: {:time, 0.999999999}) + iex> FunWithFlags.enabled?(:random_glitch) + true + iex> FunWithFlags.enable(:random_glitch, for_percentage_of: {:time, 0.000000001}) + iex> FunWithFlags.enabled?(:random_glitch) + false + + ### Enable for a percentage of the actors + + This example is based on the fact that the actor score for the actor-flag pair + `marty + :new_ui` is lower than 50%, and for the `buford + :new_ui` is higher. + + iex> FunWithFlags.disable(:new_ui) + iex> FunWithFlags.enable(:new_ui, for_percentage_of: {:actors, 0.5}) + iex> FunWithFlags.enabled?(:new_ui) + false + iex> alias FunWithFlags.TestUser, as: User + iex> marty = %User{id: 42, name: "Marty McFly"} + iex> buford = %User{id: 2, name: "Buford Tannen"} + iex> FunWithFlags.enabled?(:new_ui, for: marty) + true + iex> FunWithFlags.enabled?(:new_ui, for: buford) + false + + """ + @spec enable(atom, options) :: {:ok, true} | {:error, any} + def enable(flag_name, options \\ []) + + def enable(flag_name, []) when is_atom(flag_name) do + gate = Gate.new(:boolean, true) + case @store.put(flag_name, gate) do + {:ok, flag} -> verify(flag) + error -> error + end + end + + def enable(flag_name, [for_actor: nil]) do + enable(flag_name) + end + + def enable(flag_name, [for_actor: actor]) when is_atom(flag_name) do + gate = Gate.new(:actor, actor, true) + case @store.put(flag_name, gate) do + {:ok, flag} -> verify(flag, for: actor) + error -> error + end + end + + + def enable(flag_name, [for_group: nil]) do + enable(flag_name) + end + + def enable(flag_name, [for_group: group_name]) when is_atom(flag_name) do + gate = Gate.new(:group, group_name, true) + case @store.put(flag_name, gate) do + {:ok, _flag} -> {:ok, true} + error -> error + end + end + + + def enable(flag_name, [for_percentage_of: {:time, ratio}]) when is_atom(flag_name) do + gate = Gate.new(:percentage_of_time, ratio) + case @store.put(flag_name, gate) do + {:ok, _flag} -> {:ok, true} + error -> error + end + end + + def enable(flag_name, [for_percentage_of: {:actors, ratio}]) when is_atom(flag_name) do + gate = Gate.new(:percentage_of_actors, ratio) + case @store.put(flag_name, gate) do + {:ok, _flag} -> {:ok, true} + error -> error + end + end + + @doc """ + Disables a feature flag. + + ## Options + + * `:for_actor` - used to disable the flag for a specific term only. + The value can be any term that implements the `Actor` protocol. + * `:for_group` - used to disable the flag for a specific group only. + The value should be a binary or an atom (It's internally converted + to a binary and it's stored and retrieved as a binary. Atoms are + supported for retro-compatibility with versions <= 0.9) + * `:for_percentage_of` - used to disable the flag for a percentage + of time or actors, expressed as `{:time, float}` or `{:actors, float}`, + where float is in the range `0.0 < x < 1.0`. + + ## Examples + + ### Disable globally + + iex> FunWithFlags.enable(:random_koala_gifs) + iex> FunWithFlags.enabled?(:random_koala_gifs) + true + iex> FunWithFlags.disable(:random_koala_gifs) + {:ok, false} + iex> FunWithFlags.enabled?(:random_koala_gifs) + false + + + ## Disable for an actor + + iex> FunWithFlags.enable(:spider_sense) + {:ok, true} + iex> villain = %{name: "Venom"} + iex> FunWithFlags.disable(:spider_sense, for_actor: villain) + {:ok, false} + iex> FunWithFlags.enabled?(:spider_sense) + true + iex> FunWithFlags.enabled?(:spider_sense, for: villain) + false + + ### Disable for a group + + This example relies on the [reference implementation](https://github.com/tompave/fun_with_flags/blob/master/test/support/test_user.ex) + used in the tests. + + iex> alias FunWithFlags.TestUser, as: User + iex> harry = %User{name: "Harry Potter", groups: ["wizards", "gryffindor"]} + iex> dudley = %User{name: "Dudley Dursley", groups: ["muggles"]} + iex> FunWithFlags.enable(:hogwarts) + {:ok, true} + iex> FunWithFlags.disable(:hogwarts, for_group: "muggles") + {:ok, false} + iex> FunWithFlags.enabled?(:hogwarts) + true + iex> FunWithFlags.enabled?(:hogwarts, for: harry) + true + iex> FunWithFlags.enabled?(:hogwarts, for: dudley) + false + + ### Disable for a percentage of the time + + iex> FunWithFlags.clear(:random_glitch) + :ok + iex> FunWithFlags.disable(:random_glitch, for_percentage_of: {:time, 0.999999999}) + {:ok, false} + iex> FunWithFlags.enabled?(:random_glitch) + false + iex> FunWithFlags.disable(:random_glitch, for_percentage_of: {:time, 0.000000001}) + {:ok, false} + iex> FunWithFlags.enabled?(:random_glitch) + true + + ### Disable for a percentage of the actors + + iex> FunWithFlags.disable(:new_ui, for_percentage_of: {:actors, 0.3}) + {:ok, false} + + """ + @spec disable(atom, options) :: {:ok, false} | {:error, any} + def disable(flag_name, options \\ []) + + def disable(flag_name, []) when is_atom(flag_name) do + gate = Gate.new(:boolean, false) + case @store.put(flag_name, gate) do + {:ok, flag} -> verify(flag) + error -> error + end + end + + def disable(flag_name, [for_actor: nil]) do + disable(flag_name) + end + + def disable(flag_name, [for_actor: actor]) when is_atom(flag_name) do + gate = Gate.new(:actor, actor, false) + case @store.put(flag_name, gate) do + {:ok, flag} -> verify(flag, for: actor) + error -> error + end + end + + def disable(flag_name, [for_group: nil]) do + disable(flag_name) + end + + def disable(flag_name, [for_group: group_name]) when is_atom(flag_name) do + gate = Gate.new(:group, group_name, false) + case @store.put(flag_name, gate) do + {:ok, _flag} -> {:ok, false} + error -> error + end + end + + + def disable(flag_name, [for_percentage_of: {type, ratio}]) + when is_atom(flag_name) and is_float(ratio) do + inverted_ratio = 1.0 - ratio + case enable(flag_name, [for_percentage_of: {type, inverted_ratio}]) do + {:ok, true} -> {:ok, false} + error -> error + end + end + + + @doc """ + Clears the data of a feature flag. + + Clears the data for an entire feature flag or for a specific + Actor or Group gate. Clearing a boolean gate is not supported + because a missing boolean gate is equivalent to a disabled boolean + gate. + + Sometimes enabling or disabling a gate is not what you want, and you + need to remove that gate's rules instead. For example, if you don't need + anymore to explicitly enable or disable a flag for an actor, and the + default state should be used instead, you'll want to clear the gate. + + It's also possible to clear the entire flag, by not passing any option. + + ## Options + + * `for_actor: an_actor` - used to clear the flag for a specific term only. + The value can be any term that implements the `Actor` protocol. + * `for_group: a_group_name` - used to clear the flag for a specific group only. + The value should be a binary or an atom (It's internally converted + to a binary and it's stored and retrieved as a binary. Atoms are + supported for retro-compatibility with versions <= 0.9) + * `boolean: true` - used to clear the boolean gate. + * `for_percentage: true` - used to clear any percentage gate. + + ## Examples + + iex> alias FunWithFlags.TestUser, as: User + iex> harry = %User{id: 1, name: "Harry Potter", groups: ["wizards", "gryffindor"]} + iex> hagrid = %User{id: 2, name: "Rubeus Hagrid", groups: ["wizards", "gamekeeper"]} + iex> dudley = %User{id: 3, name: "Dudley Dursley", groups: ["muggles"]} + iex> FunWithFlags.disable(:wands) + iex> FunWithFlags.enable(:wands, for_group: "wizards") + iex> FunWithFlags.disable(:wands, for_actor: hagrid) + iex> + iex> FunWithFlags.enabled?(:wands) + false + iex> FunWithFlags.enabled?(:wands, for: harry) + true + iex> FunWithFlags.enabled?(:wands, for: hagrid) + false + iex> FunWithFlags.enabled?(:wands, for: dudley) + false + iex> + iex> FunWithFlags.clear(:wands, for_actor: hagrid) + :ok + iex> FunWithFlags.enabled?(:wands, for: hagrid) + true + iex> + iex> FunWithFlags.clear(:wands) + :ok + iex> FunWithFlags.enabled?(:wands) + false + iex> FunWithFlags.enabled?(:wands, for: harry) + false + iex> FunWithFlags.enabled?(:wands, for: hagrid) + false + iex> FunWithFlags.enabled?(:wands, for: dudley) + false + + """ + @spec clear(atom, options) :: :ok | {:error, any} + def clear(flag_name, options \\ []) + + def clear(flag_name, []) when is_atom(flag_name) do + case @store.delete(flag_name) do + {:ok, _flag} -> :ok + error -> error + end + end + + def clear(flag_name, [boolean: true]) do + gate = Gate.new(:boolean, false) # we only care about the gate id + _clear_gate(flag_name, gate) + end + + def clear(flag_name, [for_actor: nil]) do + clear(flag_name) + end + + def clear(flag_name, [for_actor: actor]) when is_atom(flag_name) do + gate = Gate.new(:actor, actor, false) # we only care about the gate id + _clear_gate(flag_name, gate) + end + + def clear(flag_name, [for_group: nil]) do + clear(flag_name) + end + + def clear(flag_name, [for_group: group_name]) when is_atom(flag_name) do + gate = Gate.new(:group, group_name, false) # we only care about the gate id + _clear_gate(flag_name, gate) + end + + def clear(flag_name, [for_percentage: true]) do + gate = Gate.new(:percentage_of_time, 0.5) # we only care about the gate id + _clear_gate(flag_name, gate) + end + + defp _clear_gate(flag_name, gate) do + case @store.delete(flag_name, gate) do + {:ok, _flag} -> :ok + error -> error + end + end + + @doc """ + Returns a list of all flag names currently configured, as atoms. + + This can be useful for debugging or for display purposes, + but it's not meant to be used at runtime. Undefined flags, + for example, will be considered disabled. + """ + @spec all_flag_names() :: {:ok, [atom]} | {:error, any} + def all_flag_names do + Config.persistence_adapter().all_flag_names() + end + + @doc """ + Returns a list of all the flags currently configured, as data structures. + + This function is provided for debugging and to build more complex + functionality (e.g. it's used in the web GUI), but it is not meant to be + used at runtime to check if a flag is enabled. + + To query the value of a flag, please use the `enabled?2` function instead. + """ + @spec all_flags() :: {:ok, [FunWithFlags.Flag.t]} | {:error, any} + def all_flags do + Config.persistence_adapter().all_flags() + end + + + @doc """ + Returns a `FunWithFlags.Flag` struct for the given name, or `nil` if + no flag is found. + + Useful for debugging. + """ + @spec get_flag(atom) :: FunWithFlags.Flag.t | nil | {:error, any} + def get_flag(name) do + case all_flag_names() do + {:ok, names} -> + if name in names do + case Config.persistence_adapter().get(name) do + {:ok, flag} -> flag + error -> error + end + else + nil + end + error -> error + end + end + + + defp verify(flag) do + {:ok, Flag.enabled?(flag)} + end + defp verify(flag, [for: data]) do + {:ok, Flag.enabled?(flag, for: data)} + end + + # Used in some tests and to debug. + # + # Apparently calling `Config.store_module_determined_at_compile_time` even + # just once in a test causes all sorts of weird test failures everywhere. + # + @doc false + def compiled_store, do: @store + end end From 6c4042d0df156a94063d173886c20280c237f17b Mon Sep 17 00:00:00 2001 From: tom Date: Fri, 16 Aug 2024 01:02:11 +0200 Subject: [PATCH 5/7] remove stray comment --- lib/fun_with_flags/entry_point.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/fun_with_flags/entry_point.ex b/lib/fun_with_flags/entry_point.ex index b767088..f1e55bf 100644 --- a/lib/fun_with_flags/entry_point.ex +++ b/lib/fun_with_flags/entry_point.ex @@ -45,7 +45,6 @@ defmodule FunWithFlags.EntryPoint do quote bind_quoted: [opts: opts] do @behaviour FunWithFlags.EntryPoint - # TODO: add what's currently in the top module. alias FunWithFlags.{Config, Flag, Gate} @store FunWithFlags.Config.store_module_determined_at_compile_time() From fd343d5d9cff87aea4e84460d8a3f728cfb8df98 Mon Sep 17 00:00:00 2001 From: tom Date: Fri, 16 Aug 2024 01:04:11 +0200 Subject: [PATCH 6/7] don't include the config callback yet --- lib/fun_with_flags.ex | 5 ----- lib/fun_with_flags/entry_point.ex | 10 ---------- 2 files changed, 15 deletions(-) diff --git a/lib/fun_with_flags.ex b/lib/fun_with_flags.ex index 6abe65c..da64c85 100644 --- a/lib/fun_with_flags.ex +++ b/lib/fun_with_flags.ex @@ -8,9 +8,4 @@ defmodule FunWithFlags do """ use FunWithFlags.EntryPoint - - @impl true - def config do - {:ok, []} - end end diff --git a/lib/fun_with_flags/entry_point.ex b/lib/fun_with_flags/entry_point.ex index f1e55bf..d4e3196 100644 --- a/lib/fun_with_flags/entry_point.ex +++ b/lib/fun_with_flags/entry_point.ex @@ -43,8 +43,6 @@ defmodule FunWithFlags.EntryPoint do @doc false defmacro __using__(opts) do quote bind_quoted: [opts: opts] do - @behaviour FunWithFlags.EntryPoint - alias FunWithFlags.{Config, Flag, Gate} @store FunWithFlags.Config.store_module_determined_at_compile_time() @@ -543,12 +541,4 @@ defmodule FunWithFlags.EntryPoint do end end - - @doc """ - An entry point must define this callback to provide its configuration. - - This function is supposed to retrieve any runtime config (e.g. ENV vars) and - return it as a keyword list. - """ - @callback config() :: {:ok, Keyword.t()} end From 4f0b195419b96c4b65afed693385dc110d4d9bda Mon Sep 17 00:00:00 2001 From: tom Date: Fri, 16 Aug 2024 01:09:57 +0200 Subject: [PATCH 7/7] Disable long-quote-blocks credo --- .credo.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.credo.exs b/.credo.exs index 6f868f8..4b72f90 100644 --- a/.credo.exs +++ b/.credo.exs @@ -125,7 +125,7 @@ {Credo.Check.Refactor.FilterCount, []}, {Credo.Check.Refactor.FilterFilter, []}, {Credo.Check.Refactor.FunctionArity, []}, - {Credo.Check.Refactor.LongQuoteBlocks, []}, + # {Credo.Check.Refactor.LongQuoteBlocks, []}, {Credo.Check.Refactor.MapJoin, []}, {Credo.Check.Refactor.MatchInCondition, []}, {Credo.Check.Refactor.NegatedConditionsInUnless, []},