defmodule Optify do @moduledoc """ High-level Elixir API for Optify feature providers. Most applications interact with Optify through the default-provider helpers: - `get_options/2` and `get_options!/2` for merged option lookup - `get_features/0` and `get_aliases/0` for feature discovery - `get_feature_metadata/1` for metadata lookup When you need more control, you can build a provider explicitly with `build!/1`, `build_with_schema!/2`, or `build_from_directories!/1` and then call the provider-explicit APIs. """ alias Optify.DefaultProvider alias Optify.GetOptionsPreferences alias Optify.Native alias Optify.OptionShapeCache alias Optify.StructCaster @type provider :: reference() @type build_config_input :: keyword() | map() | nil @doc """ Build a provider from one feature directory. Returns `{:ok, provider}` on success or `{:error, reason}` when the directory cannot be loaded. """ @spec build(String.t()) :: {:ok, provider()} | {:error, String.t()} def build(directory), do: Native.build_provider(directory) @doc """ Bang variant of `build/1`. """ @spec build!(String.t()) :: provider() def build!(directory) do case build(directory) do {:ok, provider} -> provider {:error, reason} -> raise ArgumentError, "Optify.build!/1 failed: #{reason}" end end @doc """ Build a provider from one feature directory and a JSON schema path. """ @spec build_with_schema(String.t(), String.t()) :: {:ok, provider()} | {:error, String.t()} def build_with_schema(directory, schema_path), do: Native.build_provider_with_schema(directory, schema_path) @doc """ Bang variant of `build_with_schema/2`. """ @spec build_with_schema!(String.t(), String.t()) :: provider() def build_with_schema!(directory, schema_path) do case build_with_schema(directory, schema_path) do {:ok, provider} -> provider {:error, reason} -> raise ArgumentError, "Optify.build_with_schema!/2 failed: #{reason}" end end @doc """ Build a provider from multiple feature directories. """ @spec build_from_directories([String.t()]) :: {:ok, provider()} | {:error, String.t()} def build_from_directories(directories), do: Native.build_provider_from_directories(directories) @doc """ Bang variant of `build_from_directories/1`. """ @spec build_from_directories!([String.t()]) :: provider() def build_from_directories!(directories) do case build_from_directories(directories) do {:ok, provider} -> provider {:error, reason} -> raise ArgumentError, "Optify.build_from_directories!/1 failed: #{reason}" end end @doc """ Build a provider from multiple feature directories and a JSON schema path. """ @spec build_from_directories_with_schema([String.t()], String.t()) :: {:ok, provider()} | {:error, String.t()} def build_from_directories_with_schema(directories, schema_path), do: Native.build_provider_from_directories_with_schema(directories, schema_path) @doc """ Bang variant of `build_from_directories_with_schema/2`. """ @spec build_from_directories_with_schema!([String.t()], String.t()) :: provider() def build_from_directories_with_schema!(directories, schema_path) do case build_from_directories_with_schema(directories, schema_path) do {:ok, provider} -> provider {:error, reason} -> raise ArgumentError, "Optify.build_from_directories_with_schema!/2 failed: #{reason}" end end @doc """ Build a provider from the `:optify, :provider` config shape. Supported config keys: - `:directory` - `:directories` - `:schema_path` """ @spec build_from_config(build_config_input()) :: {:ok, provider()} | {:error, String.t()} def build_from_config(config \\ nil) def build_from_config(nil), do: build_from_config(Application.get_env(:optify, :provider, [])) def build_from_config(config) when is_list(config) or is_map(config) do case extract_build_config(config) do {:directory, directory, nil} -> build(directory) {:directory, directory, schema_path} -> build_with_schema(directory, schema_path) {:directories, directories, nil} -> build_from_directories(directories) {:directories, directories, schema_path} -> build_from_directories_with_schema(directories, schema_path) :error -> {:error, missing_provider_config_error()} end end @doc """ Bang variant of `build_from_config/1`. """ @spec build_from_config!(build_config_input()) :: provider() def build_from_config!(config \\ nil) do case build_from_config(config) do {:ok, provider} -> provider {:error, reason} -> raise ArgumentError, "Optify.build_from_config!/1 failed: #{reason}" end end @doc """ Set the process-global default provider used by the convenience APIs. """ @spec set_default_provider(provider()) :: :ok def set_default_provider(provider), do: DefaultProvider.set(provider) @doc """ Return the current default provider, or `nil` if one has not been loaded. """ @spec default_provider() :: provider() | nil def default_provider, do: DefaultProvider.get() @doc """ Return the current default provider. Raises if no default provider has been loaded. """ @spec default_provider!() :: provider() def default_provider!, do: DefaultProvider.get!() @doc """ Build and store the default provider from the given config. When `config` is omitted, this reads from `Application.get_env(:optify, :provider)`. """ @spec load_default_provider!(keyword() | map() | nil) :: provider() def load_default_provider!(config \\ nil) do provider = build_from_config!(config) set_default_provider(provider) provider end @doc false @spec features() :: [String.t()] def features, do: get_features() @doc false @spec features(provider()) :: [String.t()] def features(provider), do: get_features(provider) @doc """ Return all canonical feature names from the default provider. """ @spec get_features() :: [String.t()] def get_features, do: get_features(default_provider!()) @doc """ Return all canonical feature names from the given provider. """ @spec get_features(provider()) :: [String.t()] def get_features(provider), do: Native.features(provider) @doc """ Return all configured aliases from the default provider. """ @spec get_aliases() :: [String.t()] def get_aliases, do: get_aliases(default_provider!()) @doc """ Return all configured aliases from the given provider. """ @spec get_aliases(provider()) :: [String.t()] def get_aliases(provider), do: Native.get_aliases(provider) @doc """ Return canonical feature names and aliases from the default provider. """ @spec get_features_and_aliases() :: [String.t()] def get_features_and_aliases, do: get_features_and_aliases(default_provider!()) @doc """ Return canonical feature names and aliases from the given provider. """ @spec get_features_and_aliases(provider()) :: [String.t()] def get_features_and_aliases(provider), do: Native.get_features_and_aliases(provider) @doc """ Resolve a feature name or alias to its canonical feature name using the default provider. """ @spec get_canonical_feature_name(String.t()) :: {:ok, String.t()} | {:error, String.t()} def get_canonical_feature_name(feature_name), do: get_canonical_feature_name(default_provider!(), feature_name) @doc """ Resolve a feature name or alias to its canonical feature name using the given provider. """ @spec get_canonical_feature_name(provider(), String.t()) :: {:ok, String.t()} | {:error, String.t()} def get_canonical_feature_name(provider, feature_name), do: Native.get_canonical_feature_name(provider, feature_name) @doc """ Bang variant of `get_canonical_feature_name/1`. """ @spec get_canonical_feature_name!(String.t()) :: String.t() def get_canonical_feature_name!(feature_name) do case get_canonical_feature_name(feature_name) do {:ok, canonical_feature_name} -> canonical_feature_name {:error, reason} -> raise ArgumentError, "Optify.get_canonical_feature_name!/1 failed: #{reason}" end end @doc """ Resolve multiple feature names or aliases using the default provider. """ @spec get_canonical_feature_names([String.t()]) :: {:ok, [String.t()]} | {:error, String.t()} def get_canonical_feature_names(feature_names), do: get_canonical_feature_names(default_provider!(), feature_names) @doc """ Resolve multiple feature names or aliases using the given provider. """ @spec get_canonical_feature_names(provider(), [String.t()]) :: {:ok, [String.t()]} | {:error, String.t()} def get_canonical_feature_names(provider, feature_names), do: Native.get_canonical_feature_names(provider, feature_names) @doc """ Bang variant of `get_canonical_feature_names/1`. """ @spec get_canonical_feature_names!([String.t()]) :: [String.t()] def get_canonical_feature_names!(feature_names) do case get_canonical_feature_names(feature_names) do {:ok, canonical_feature_names} -> canonical_feature_names {:error, reason} -> raise ArgumentError, "Optify.get_canonical_feature_names!/1 failed: #{reason}" end end @doc """ Return metadata for one canonical feature from the default provider. """ @spec get_feature_metadata(String.t()) :: map() | nil def get_feature_metadata(canonical_feature_name), do: get_feature_metadata(default_provider!(), canonical_feature_name) @doc """ Return metadata for one canonical feature from the given provider. """ @spec get_feature_metadata(provider(), String.t()) :: map() | nil def get_feature_metadata(provider, canonical_feature_name) do case Native.get_feature_metadata_json(provider, canonical_feature_name) do nil -> nil json -> decode_json!(json) end end @doc """ Return metadata for all features from the default provider. """ @spec get_features_with_metadata() :: map() def get_features_with_metadata, do: get_features_with_metadata(default_provider!()) @doc """ Return metadata for all features from the given provider. """ @spec get_features_with_metadata(provider()) :: map() def get_features_with_metadata(provider) do provider |> Native.get_features_with_metadata_json() |> decode_json!() end @doc """ Filter feature names using the default provider and default preferences. """ @spec get_filtered_feature_names([String.t()]) :: {:ok, [String.t()]} | {:error, String.t()} def get_filtered_feature_names(feature_names), do: get_filtered_feature_names(feature_names, %GetOptionsPreferences{}) @doc """ Filter feature names using the default provider and the given preferences. """ @spec get_filtered_feature_names([String.t()], GetOptionsPreferences.input_t()) :: {:ok, [String.t()]} | {:error, String.t()} def get_filtered_feature_names(feature_names, preferences) when is_list(feature_names) do get_filtered_feature_names(default_provider!(), feature_names, preferences) end @doc """ Filter feature names using the given provider and preferences. """ @spec get_filtered_feature_names(provider(), [String.t()], GetOptionsPreferences.input_t()) :: {:ok, [String.t()]} | {:error, String.t()} def get_filtered_feature_names(provider, feature_names, preferences) do Native.get_filtered_feature_names( provider, feature_names, GetOptionsPreferences.to_nif_map(preferences) ) end @doc """ Bang variant of `get_filtered_feature_names/1`. """ @spec get_filtered_feature_names!([String.t()]) :: [String.t()] def get_filtered_feature_names!(feature_names), do: get_filtered_feature_names!(feature_names, %GetOptionsPreferences{}) @doc """ Bang variant of `get_filtered_feature_names/2`. """ @spec get_filtered_feature_names!([String.t()], GetOptionsPreferences.input_t()) :: [String.t()] def get_filtered_feature_names!(feature_names, preferences) when is_list(feature_names) do case get_filtered_feature_names(feature_names, preferences) do {:ok, resolved_feature_names} -> resolved_feature_names {:error, reason} -> raise ArgumentError, "Optify.get_filtered_feature_names!/2 failed: #{reason}" end end @doc """ Bang variant of `get_filtered_feature_names/3`. """ @spec get_filtered_feature_names!(provider(), [String.t()], GetOptionsPreferences.input_t()) :: [String.t()] def get_filtered_feature_names!(provider, feature_names, preferences) do case get_filtered_feature_names(provider, feature_names, preferences) do {:ok, resolved_feature_names} -> resolved_feature_names {:error, reason} -> raise ArgumentError, "Optify.get_filtered_feature_names!/3 failed: #{reason}" end end @doc """ Return whether the canonical feature has conditions in the default provider. """ @spec has_conditions(String.t()) :: boolean() def has_conditions(canonical_feature_name), do: has_conditions(default_provider!(), canonical_feature_name) @doc """ Return whether the canonical feature has conditions in the given provider. """ @spec has_conditions(provider(), String.t()) :: boolean() def has_conditions(provider, canonical_feature_name), do: Native.has_conditions(provider, canonical_feature_name) @doc """ Get merged options using the default provider. Returns atom-keyed maps by default so dot access works (e.g. `options.flow`). Known nested option paths are hydrated with `nil` defaults so `options.flow.handler` remains safe even when `flow` is absent from the selected feature set. """ @spec get_options([String.t()], keyword()) :: {:ok, map() | list() | String.t() | number() | boolean() | nil} | {:error, String.t()} def get_options(feature_names, opts \\ []) when is_list(feature_names) and is_list(opts) do provider = Keyword.get(opts, :provider, default_provider!()) preferences = Keyword.get(opts, :preferences, %GetOptionsPreferences{}) key_mode = Keyword.get(opts, :keys, :atoms) as_module = Keyword.get(opts, :as) with {:ok, options} <- get_all_options(provider, feature_names, preferences) do options |> hydrate_known_paths(provider) |> transform_keys(key_mode) |> cast_output(as_module) end end @doc """ Bang variant of `get_options/2`. """ @spec get_options!([String.t()], keyword()) :: map() | list() | String.t() | number() | boolean() | nil def get_options!(feature_names, opts \\ []) when is_list(feature_names) and is_list(opts) do case get_options(feature_names, opts) do {:ok, options} -> options {:error, reason} -> raise ArgumentError, "Optify.get_options!/2 failed: #{reason}" end end @doc """ Get options for one top-level key from the given provider. """ @spec get_options(provider(), String.t(), [String.t()], GetOptionsPreferences.input_t()) :: {:ok, map() | list() | String.t() | number() | boolean() | nil} | {:error, String.t()} def get_options(provider, key, feature_names, preferences \\ %GetOptionsPreferences{}) do with {:ok, json} <- Native.get_options_json_with_preferences( provider, key, feature_names, GetOptionsPreferences.to_nif_map(preferences) ) do decode_json(json) end end @doc """ Bang variant of `get_options/4`. """ @spec get_options!(provider(), String.t(), [String.t()], GetOptionsPreferences.input_t()) :: map() | list() | String.t() | number() | boolean() | nil def get_options!(provider, key, feature_names, preferences \\ %GetOptionsPreferences{}) do case get_options(provider, key, feature_names, preferences) do {:ok, options} -> options {:error, reason} -> raise ArgumentError, "Optify.get_options!/4 failed: #{reason}" end end @doc false @spec get_all_options(provider(), [String.t()], GetOptionsPreferences.input_t()) :: {:ok, map() | list() | String.t() | number() | boolean() | nil} | {:error, String.t()} def get_all_options(provider, feature_names, preferences \\ %GetOptionsPreferences{}) do with {:ok, json} <- Native.get_all_options_json_with_preferences( provider, feature_names, GetOptionsPreferences.to_nif_map(preferences) ) do decode_json(json) end end @doc false @spec get_all_options!(provider(), [String.t()], GetOptionsPreferences.input_t()) :: map() | list() | String.t() | number() | boolean() | nil def get_all_options!(provider, feature_names, preferences \\ %GetOptionsPreferences{}) do case get_all_options(provider, feature_names, preferences) do {:ok, options} -> options {:error, reason} -> raise ArgumentError, "Optify.get_all_options!/3 failed: #{reason}" end end @doc false @spec get_options_json(provider(), String.t(), [String.t()], GetOptionsPreferences.input_t()) :: {:ok, String.t()} | {:error, String.t()} def get_options_json(provider, key, feature_names, preferences \\ %GetOptionsPreferences{}) do Native.get_options_json_with_preferences( provider, key, feature_names, GetOptionsPreferences.to_nif_map(preferences) ) end @doc false @spec get_all_options_json(provider(), [String.t()], GetOptionsPreferences.input_t()) :: {:ok, String.t()} | {:error, String.t()} def get_all_options_json(provider, feature_names, preferences \\ %GetOptionsPreferences{}) do Native.get_all_options_json_with_preferences( provider, feature_names, GetOptionsPreferences.to_nif_map(preferences) ) end @doc false @spec get_options_json!(provider(), String.t(), [String.t()], GetOptionsPreferences.input_t()) :: String.t() def get_options_json!(provider, key, feature_names, preferences \\ %GetOptionsPreferences{}) do case get_options_json(provider, key, feature_names, preferences) do {:ok, json} -> json {:error, reason} -> raise ArgumentError, "Optify.get_options_json!/4 failed: #{reason}" end end @doc false @spec get_all_options_json!(provider(), [String.t()], GetOptionsPreferences.input_t()) :: String.t() def get_all_options_json!(provider, feature_names, preferences \\ %GetOptionsPreferences{}) do case get_all_options_json(provider, feature_names, preferences) do {:ok, json} -> json {:error, reason} -> raise ArgumentError, "Optify.get_all_options_json!/3 failed: #{reason}" end end @doc false @spec warm_option_shape(provider()) :: :ok def warm_option_shape(provider) do _shape = option_shape(provider) :ok end defp cast_output(value, nil), do: {:ok, value} defp cast_output(value, module) when is_atom(module), do: StructCaster.cast(module, value) defp hydrate_known_paths(nil, provider), do: default_for_shape(option_shape(provider)) defp hydrate_known_paths(value, provider) when is_map(value) do fill_missing_paths(value, option_shape(provider)) end defp hydrate_known_paths(value, _provider), do: value defp transform_keys(value, :strings), do: value defp transform_keys(value, :atoms) when is_map(value) do value |> Enum.map(fn {k, v} -> {to_atom_key(k), transform_keys(v, :atoms)} end) |> Map.new() end defp transform_keys(value, :atoms) when is_list(value), do: Enum.map(value, &transform_keys(&1, :atoms)) defp transform_keys(value, _), do: value defp to_atom_key(key) when is_atom(key), do: key defp to_atom_key(key) when is_binary(key), do: String.to_atom(key) defp to_atom_key(key), do: key defp option_shape(provider) do OptionShapeCache.fetch(provider, fn -> build_option_shape(provider) end) end defp build_option_shape(provider) do provider |> get_features() |> Enum.reduce(%{}, fn feature_name, acc -> feature_shape = provider |> get_all_options!([feature_name]) |> build_shape() merge_shapes(acc, feature_shape) end) end defp build_shape(value) when is_map(value) do Map.new(value, fn {key, nested_value} -> {key, build_shape(nested_value)} end) end defp build_shape(_value), do: nil defp merge_shapes(left, right) when is_map(left) and is_map(right) do Map.merge(left, right, fn _key, left_value, right_value -> merge_shapes(left_value, right_value) end) end defp merge_shapes(left, right) when is_map(left) and not is_map(right), do: left defp merge_shapes(left, right) when not is_map(left) and is_map(right), do: right defp merge_shapes(_left, right), do: right defp fill_missing_paths(value, shape) when is_map(value) and is_map(shape) do Enum.reduce(shape, value, fn {key, nested_shape}, acc -> case Map.fetch(acc, key) do {:ok, nested_value} -> Map.put(acc, key, fill_missing_value(nested_value, nested_shape)) :error -> Map.put(acc, key, default_for_shape(nested_shape)) end end) end defp fill_missing_value(nil, nested_shape) when is_map(nested_shape), do: default_for_shape(nested_shape) defp fill_missing_value(value, nested_shape) when is_map(value) and is_map(nested_shape) do fill_missing_paths(value, nested_shape) end defp fill_missing_value(value, _nested_shape), do: value defp default_for_shape(shape) when is_map(shape), do: fill_missing_paths(%{}, shape) defp default_for_shape(_shape), do: nil defp extract_build_config(config) when is_list(config) do schema_path = Keyword.get(config, :schema_path) || Keyword.get(config, :schema) cond do directory = Keyword.get(config, :directory) -> {:directory, directory, schema_path} directories = Keyword.get(config, :directories) -> {:directories, directories, schema_path} true -> :error end end defp extract_build_config(config) when is_map(config) do schema_path = Map.get(config, :schema_path) || Map.get(config, "schema_path") || Map.get(config, :schema) || Map.get(config, "schema") cond do directory = Map.get(config, :directory) || Map.get(config, "directory") -> {:directory, directory, schema_path} directories = Map.get(config, :directories) || Map.get(config, "directories") -> {:directories, directories, schema_path} true -> :error end end defp missing_provider_config_error do "Missing provider config. Set :directory or :directories in config :optify, :provider" end defp decode_json(json) do case Jason.decode(json) do {:ok, decoded} -> {:ok, decoded} {:error, %Jason.DecodeError{} = err} -> {:error, Exception.message(err)} end end defp decode_json!(json) do case decode_json(json) do {:ok, decoded} -> decoded {:error, reason} -> raise ArgumentError, "Optify JSON decode failed: #{reason}" end end end