src/conf_yaml_backend.erl

%%%-------------------------------------------------------------------
%%% @author Evgeny Khramtsov <xramtsov@gmail.com>
%%%
%%% Licensed under the Apache License, Version 2.0 (the "License");
%%% you may not use this file except in compliance with the License.
%%% You may obtain a copy of the License at
%%%
%%%     http://www.apache.org/licenses/LICENSE-2.0
%%%
%%% Unless required by applicable law or agreed to in writing, software
%%% distributed under the License is distributed on an "AS IS" BASIS,
%%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%%% See the License for the specific language governing permissions and
%%% limitations under the License.
%%%
%%%-------------------------------------------------------------------
-module(conf_yaml_backend).
-behaviour(conf_backend).

%% API
-export([decode/1]).
-export([validate/1]).
-export([mime_types/0]).
-export([format_error/1]).
-export_type([error_reason/0]).

-type yaml() :: term(). %% TODO: must be fast_yaml:yaml(), but it's not exported
-type yaml_error_reason() :: {bad_yaml, term()}.
-type error_reason() :: {unsupported_application, atom()} |
                        {invalid_yaml_config, yval:error_reason(), yval:ctx()} |
                        {bad_ref, binary(), yaml_error_reason() | conf_file:error_reason()} |
                        {bad_env, conf:error_reason()} |
                        {bad_mod, conf_misc:error_reason()} |
                        {circular_ref, binary()} |
                        yaml_error_reason().
-type distance_cache() :: #{{string(), string()} => non_neg_integer()}.

%%%===================================================================
%%% conf_backend callbacks
%%%===================================================================
-spec decode(iodata()) -> {ok, yaml()} | {error, error_reason()}.
decode(Data) ->
    case fast_yaml:decode(Data) of
        {ok, [Y]} ->
            {ok, Y};
        {ok, []} ->
            {ok, []};
        {error, Reason} ->
            {error, {bad_yaml, Reason}}
    end.

-spec validate(yaml()) -> {ok, conf:apps_config()} | {error, error_reason()}.
validate(Y0) ->
    try yval:validate(refs_validator(), Y0) of
        {ok, Y} ->
            case yval:validate(top_validator(), Y) of
                {ok, AppOpts} ->
                    case create_validators(AppOpts) of
                        {ok, Validators} ->
                            Validator = yval:options(Validators),
                            case yval:validate(Validator, AppOpts) of
                                {ok, Config} ->
                                    {ok, Config};
                                {error, Reason, Ctx} ->
                                    {error, {invalid_yaml_config, Reason, Ctx}}
                            end;
                        {error, _} = Err ->
                            Err
                    end;
                {error, Reason, Ctx} ->
                    {error, {invalid_yaml_config, Reason, Ctx}}
            end;
        {error, Reason, Ctx} ->
            {error, {invalid_yaml_config, Reason, Ctx}}
    catch _:{?MODULE, Reason, _Ctx} ->
            {error, Reason}
    end.

-spec mime_types() -> [binary(), ...].
mime_types() ->
    [<<"application/json">>, <<"application/yaml">>,
     <<"application/x-yaml">>, <<"application/octet-stream">>,
     <<"text/x-yaml">>, <<"text/plain">>].

-spec format_error(error_reason()) -> unicode:chardata().
format_error({unsupported_application, App}) ->
    "Erlang application '" ++ atom_to_list(App) ++ "' doesn't support YAML configuration";
format_error({invalid_yaml_config, {bad_enum, Known, Bad}, Ctx}) ->
    format_ctx(Ctx) ++
        format("Unexpected value: ~s. Did you mean '~s'? ~s",
               [Bad, best_match(Bad, Known),
                format_known("Possible values", Known)]);
format_error({invalid_yaml_config, {unknown_option, [], Opt}, Ctx}) ->
    format_ctx(Ctx) ++
        format("Unknown parameter: ~s. There are no available parameters", [Opt]);
format_error({invalid_yaml_config, {unknown_option, Known, Opt}, Ctx}) ->
    format_ctx(Ctx) ++
        format("Unknown parameter: ~s. Did you mean '~s'? ~s",
               [Opt, best_match(Opt, Known),
                format_known("Available parameters", Known)]);
format_error({invalid_yaml_config, Reason, Ctx}) ->
    yval:format_error(Reason, Ctx);
format_error({depth_limit, Limit}) ->
    format("Depth limit reached: ~B", [Limit]);
format_error({bad_ref, Ref, Reason}) ->
    format("Failed to read from ~ts: ~s",
           [Ref, case Reason of
                     {bad_yaml, _} -> format_error(Reason);
                     _ -> conf_file:format_error(Reason)
                 end]);
format_error({circular_ref, Ref}) ->
    format("Circularly defined reference: ~ts", [Ref]);
format_error({bad_yaml, Reason}) ->
    "Malformed YAML: " ++ fast_yaml:format_error(Reason);
format_error({bad_env, Reason}) ->
    conf_env:format_error(Reason);
format_error({bad_mod, Reason}) ->
    conf_misc:format_error(Reason).

%%%===================================================================
%%% Internal functions
%%%===================================================================
-spec create_validators([{atom(), term()}]) ->
                        {ok, yval:validators()} | {error, error_reason()}.
create_validators(AppOpts) ->
    lists:foldl(
      fun({App, _Opts}, {ok, Acc}) ->
              case callback_module(App) of
                  {ok, Mod} ->
                      Validator = Mod:validator(),
                      {ok, Acc#{App => Validator}};
                  {error, _} = Err ->
                      Err
              end;
         (_, {error, _} = Err) ->
              Err
      end, {ok, #{}}, AppOpts).

top_validator() ->
    yval:map(yval:atom(), yval:any(), [unique]).

-spec callback_module(atom()) -> {ok, module()} | {error, error_reason()}.
callback_module(App) ->
    case conf_env:callback_module(App) of
        {ok, Mod} ->
            case conf_misc:try_load(Mod, validator, 0) of
                {ok, _} = OK ->
                    OK;
                {error, Reason} ->
                    {error, {bad_mod, Reason}}
            end;
        {error, {undefined_env, _}} ->
            Mod = list_to_atom(atom_to_list(App) ++ "_yaml"),
            case conf_misc:try_load(Mod, validator, 0) of
                {ok, _} = OK ->
                    OK;
                {error, _} ->
                    {error, {unsupported_application, App}}
            end;
        {error, Reason} ->
            {error, {bad_env, Reason}}
    end.

-spec refs_validator() -> yval:validator().
refs_validator() ->
    refs_validator(0, []).

%% FIXME: currently this validator doesn't track context so
%% in the case of a failure it's clueless where it has occured.
-spec refs_validator(non_neg_integer(), [binary()]) -> yval:validator().
refs_validator(Limit = 100, _) ->
    yval:fail(?MODULE, {depth_limit, Limit});
refs_validator(Level, Paths) ->
    fun([{_, _}|_] = Y) ->
            lists:flatmap(
              fun({Key, Val}) when Key == <<"$ref">> orelse Key == '$ref' ->
                      Path = (yval:non_empty(yval:binary()))(Val),
                      case conf_file:path_to_ref(Path) of
                          {ok, Ref} ->
                              Path0 = conf_file:format_ref(Ref),
                              case lists:member(Path0, Paths) of
                                  true ->
                                      yval:fail(?MODULE, {circular_ref, Path0});
                                  false ->
                                      case read_ref(Ref) of
                                          {ok, IncludeY} ->
                                              (refs_validator(Level+1, [Path0|Paths]))(IncludeY);
                                          {error, Reason} ->
                                              yval:fail(?MODULE, {bad_ref, Path0, Reason})
                                      end
                              end;
                          {error, Reason} ->
                              yval:fail(?MODULE, {bad_ref, Path, Reason})
                      end;
                 ({Key, Val}) ->
                      [{Key, (refs_validator(Level+1, Paths))(Val)}]
              end, Y);
       (Y) when is_list(Y) ->
            (yval:list(refs_validator(Level+1, Paths)))(Y);
       (Y) ->
            Y
    end.

-spec read_ref(conf_file:ref()) -> {ok, yaml()} |
                                   {error, yaml_error_reason()} |
                                   {error, conf_file:error_reason()}.
read_ref(Ref) ->
    case conf_file:read(Ref, mime_types()) of
        {ok, Data} -> decode(Data);
        {error, _} = Err -> Err
    end.

%%%===================================================================
%%% Formatters
%%%===================================================================
-spec format_ctx(yval:ctx()) -> string().
format_ctx([]) ->
    "";
format_ctx(Ctx) ->
    yval:format_ctx(Ctx) ++ ": ".

-spec format(iodata(), list()) -> string().
format(Fmt, Args) ->
    lists:flatten(io_lib:format(Fmt, Args)).

-spec format_known(string(), [atom() | binary() | string()]) -> iolist().
format_known(_, Known) when length(Known) > 20 ->
    "";
format_known(Prefix, Known) ->
    [Prefix, " are: ", format_join(Known)].

-spec format_join([atom() | string() | binary()]) -> string().
format_join([]) ->
    "(empty)";
format_join(L) ->
    Strings = lists:map(fun to_string/1, L),
    lists:join(", ", lists:sort(Strings)).

-spec best_match(atom() | binary() | string(),
                 [atom() | binary() | string()]) -> string().
best_match(Pattern, []) ->
    Pattern;
best_match(Pattern, Opts) ->
    String = to_string(Pattern),
    {Ds, _} = lists:mapfoldl(
                fun(Opt, Cache) ->
                        SOpt = to_string(Opt),
                        {Distance, Cache1} = ld(String, SOpt, Cache),
                        {{Distance, SOpt}, Cache1}
                end, #{}, Opts),
    element(2, lists:min(Ds)).

%% Levenshtein distance
-spec ld(string(), string(), distance_cache()) -> {non_neg_integer(), distance_cache()}.
ld([] = S, T, Cache) ->
    {length(T), maps:put({S, T}, length(T), Cache)};
ld(S, [] = T, Cache) ->
    {length(S), maps:put({S, T}, length(S), Cache)};
ld([X|S], [X|T], Cache) ->
    ld(S, T, Cache);
ld([_|ST] = S, [_|TT] = T, Cache) ->
    try {maps:get({S, T}, Cache), Cache}
    catch _:{badkey, _} ->
            {L1, C1} = ld(S, TT, Cache),
            {L2, C2} = ld(ST, T, C1),
            {L3, C3} = ld(ST, TT, C2),
            L = 1 + lists:min([L1, L2, L3]),
            {L, maps:put({S, T}, L, C3)}
    end.

-spec to_string(atom() | binary() | string()) -> string().
to_string(A) when is_atom(A) ->
    atom_to_list(A);
to_string(B) when is_binary(B) ->
    binary_to_list(B);
to_string(S) ->
    S.