defmodule ConfigCat.LocalFileDataSource do
@moduledoc """
Load flag overrides from a file.
See `ConfigCat.OverrideDataSource` for more details.
"""
use TypedStruct
alias ConfigCat.Config
alias ConfigCat.OverrideDataSource
require ConfigCat.ConfigCatLogger, as: ConfigCatLogger
defmodule FileCache do
@moduledoc false
use Agent
use TypedStruct
typedstruct do
field :cached_timestamp, non_neg_integer(), default: 0
field :config, Config.t()
end
@spec start_link(GenServer.options()) :: Agent.on_start()
def start_link(_opts) do
Agent.start_link(fn -> %__MODULE__{} end)
end
@spec cached_config(Agent.agent()) :: {:ok, Config.t()} | {:error, :not_found}
def cached_config(cache) do
case Agent.get(cache, fn %__MODULE__{config: config} -> config end) do
nil -> {:error, :not_found}
config -> {:ok, config}
end
end
@spec cached_timestamp(Agent.agent()) :: integer()
def cached_timestamp(cache) do
Agent.get(cache, fn %__MODULE__{cached_timestamp: timestamp} -> timestamp end)
end
@spec update(Agent.agent(), Config.t(), integer()) :: :ok
def update(cache, config, timestamp) do
Agent.update(cache, fn %__MODULE__{} = state ->
%{state | cached_timestamp: timestamp, config: config}
end)
end
end
typedstruct enforce: true do
field :cache, pid()
field :filename, String.t()
field :override_behaviour, OverrideDataSource.behaviour()
end
@doc """
Create a `ConfigCat.OverrideDataSource` that loads overrides from a file.
"""
@spec new(String.t(), OverrideDataSource.behaviour()) :: t
def new(filename, override_behaviour) do
unless File.exists?(filename) do
ConfigCatLogger.error(
"Cannot find the local config file '#{filename}'. This is a path that your application provided to the ConfigCat SDK by passing it to the `LocalFileDataSource.new()` function. Read more: https://configcat.com/docs/sdk-reference/elixir/#json-file",
event_id: 1300
)
end
{:ok, pid} = FileCache.start_link([])
%__MODULE__{cache: pid, filename: filename, override_behaviour: override_behaviour}
end
defimpl OverrideDataSource do
alias ConfigCat.Config.Setting
alias ConfigCat.LocalFileDataSource
@spec behaviour(LocalFileDataSource.t()) :: OverrideDataSource.behaviour()
def behaviour(data_source), do: data_source.override_behaviour
@spec overrides(LocalFileDataSource.t()) :: Config.t()
def overrides(%{cache: cache} = data_source) do
refresh_cache(cache, data_source.filename)
case FileCache.cached_config(cache) do
{:ok, config} -> config
_ -> %{}
end
end
defp refresh_cache(cache, filename) do
with {:ok, %{mtime: timestamp}} <- File.stat(filename, time: :posix) do
unless FileCache.cached_timestamp(cache) == timestamp do
with {:ok, contents} <- File.read(filename),
{:ok, data} <- Jason.decode(contents) do
config = normalize(data)
FileCache.update(cache, config, timestamp)
else
error ->
log_error(error, filename)
:ok
end
end
end
end
defp log_error({:error, %Jason.DecodeError{} = error}, filename) do
ConfigCatLogger.error(
"Failed to decode JSON from the local config file #{filename}. #{Exception.message(error)}",
event_id: 2302
)
end
defp log_error({:error, error}, filename) do
ConfigCatLogger.error(
"Failed to read the local config file '#{filename}'. #{:file.format_error(error)}",
event_id: 1302
)
end
defp normalize(%{"flags" => source} = _data) do
settings =
source
|> Enum.map(fn {key, value} -> {key, Setting.new(value: value)} end)
|> Map.new()
Config.new(settings: settings)
end
defp normalize(source) do
Config.inline_salt_and_segments(source)
end
end
end