src/nine.erl

-module(nine).

-export([compile/1,
         compile/2,
         compile/3,
         compile/4,
         compile_routes/1]).

compile(#{routes := RouteConfig,
          router := Router,
          generator := Generator}) ->
    compile(RouteConfig, Router, Generator).

compile(#{routes := RouteConfig,
          router := Router,
          generator := Generator}, DirPath) ->
    compile(RouteConfig, Router, Generator, DirPath).

compile(RouteConfig, Router, Generator) ->
    Forms = do_compile(RouteConfig, Router, Generator),
    compile_binary(Forms).

compile(RouteConfig, Router, Generator, DirPath) ->
    Forms = do_compile(RouteConfig, Router, Generator),
    compile_file(Forms, DirPath).

do_compile(RouteConfig, Router, Generator) ->
    NormalizedRoutes = compile_routes(RouteConfig),
    Generator:generate(Router, NormalizedRoutes).

compile_file(Forms, DirPath) ->
    {ok, Module, Bin} = compile:forms(Forms, [debug_info]),
    FinalPath = filename:join(DirPath, module_to_beam_filename(Module)),
    file:write_file(FinalPath, Bin).

compile_binary(Forms) ->
    {ok, Module, Binary} = compile:forms(Forms),
    code:load_binary(Module, module_to_beam_filename(Module), Binary).

module_to_beam_filename(Module) ->
    binary_to_list(list_to_binary([
        atom_to_binary(Module),
        ".beam"
    ])).

compile_routes(Config) when is_list(Config) ->
    lists:concat(lists:map(fun compile_routes/1, Config));
compile_routes(Config) when is_map(Config) ->
    compile_routes(Config, #{<<"pre">> => [], 
                      <<"post">> => [], 
                      <<"routes">> => [],
                      <<"method">> => <<"_">>, 
                      <<"path">> => <<"/">>}).

compile_routes(Config, Context) when is_list(Config) ->
    lists:concat(lists:map(fun (C) -> compile_routes(C, Context) end, Config));
compile_routes(Config, Context) ->
    do_compile(validate_config(normalize_keys(Config)), Context).

normalize_keys(Config) ->
    L = maps:to_list(Config),
    L2 = lists:map(fun normalize_key/1, L),
    maps:from_list(L2).

normalize_key({K, V}) when is_atom(K) ->
    {atom_to_binary(K), V};
normalize_key({K, _V} = Pair) when is_binary(K) ->
    Pair;
normalize_key(_) ->
    throw("Expected key to be binary or atom").

validate_config(#{<<"handle">> := _Handler} = Config) ->
    Config;
validate_config(_Config) ->
    throw("Missing handle key in config").

do_compile(#{<<"pre">> := PreConfig} = Config, #{<<"pre">> := PreContext} = Context) ->
    NewPre = normalize_middleware(PreConfig),
    do_compile(maps:remove(<<"pre">>, Config), Context#{<<"pre">> => PreContext ++ NewPre});
do_compile(#{<<"post">> := PostConfig} = Config, #{<<"post">> := PostContext} = Context) ->
    NewPost = normalize_middleware(PostConfig),
    do_compile(maps:remove(<<"post">>, Config), Context#{<<"post">> => PostContext ++ NewPost});
do_compile(#{<<"path">> := PathConfig} = Config, #{<<"path">> := PathContext} = Context) ->
    PathConfig2 =
    case starts_with(PathConfig, <<"/">>) of
        true ->
            PathConfig;
        false ->
            list_to_binary([<<"/">>, PathConfig])
    end,
    do_compile(maps:remove(<<"path">>, Config), Context#{<<"path">> => url_join(PathContext, PathConfig2)});
do_compile(#{<<"method">> := MethodConfig} = Config, #{<<"method">> := _MethodContext} = Context) ->
    do_compile(maps:remove(<<"method">>, Config), Context#{<<"method">> => MethodConfig});
do_compile(#{<<"handle">> := HandleConfig}=Config, #{<<"routes">> := Routes,
                                                     <<"path">> := PathContext,
                                                     <<"method">> := MethodContext,
                                                     <<"pre">> := PreContext,
                                                     <<"post">> := PostContext} = Context) ->
    Result =
    case HandleConfig of
        {_Module, _Function} = Route ->
            [{PathContext, MethodContext, PreContext ++ [Route] ++ PostContext}];
        InnerConfig when is_list(InnerConfig) orelse is_map(InnerConfig) ->
            compile_routes(InnerConfig, Context)
    end,
    do_compile(maps:remove(<<"handle">>, Config), Context#{<<"routes">> => Routes ++ Result});
do_compile(_Config, #{<<"routes">> := RoutesContext}) ->
    RoutesContext.

-spec normalize_middleware(tuple() | list()) -> list().
normalize_middleware(Mid) when is_tuple(Mid) ->
    [Mid];
normalize_middleware(Mid) when is_list(Mid) ->
    Mid.

-spec url_join(binary(), binary()) -> binary().
url_join(A, B) ->
    list_to_binary([
       string:trim(A, trailing, "/"),
       <<"/">>,
       string:trim(B, both, "/") 
    ]).

-spec starts_with(binary(), binary()) -> boolean().
starts_with(S, Prefix) ->
    string:prefix(S, Prefix) =/= S.
 
    
-ifdef(TEST).

-include_lib("eunit/include/eunit.hrl").

url_join_test() ->
    ?assertEqual(<<"/admin/login">>, url_join(<<"/admin">>, <<"/login">>)).

starts_with_test() ->
    ?assert(starts_with(<<"/admin">>, <<"/">>)).

compile_routes_test() ->
    Config = [#{<<"path">> => <<"foo">>,
               <<"method">> => <<"GET">>,
               <<"handle">> => {foo, bar}}],
    Result = compile_routes(Config),
    Expected = [{<<"/foo">>, <<"GET">>, [{foo, bar}]}],
    ?assertEqual(Expected, Result).

compile_routes2_test() ->
    Config = [#{<<"path">> => <<"foo">>,
               <<"method">> => <<"GET">>,
               <<"handle">> => {foo, bar},
               <<"pre">> => [{auth_mid, verify}],
               <<"post">> => [{log_mid, info}]}],
    Result = compile_routes(Config),
    Expected = [{<<"/foo">>, <<"GET">>, [{auth_mid, verify}, {foo, bar}, {log_mid, info}]}],
    ?assertEqual(Expected, Result).

compile_routes4_test() ->
    Config = [#{<<"handle">> => {foo, bar}}],
    Result = compile_routes(Config),
    Expected = [{<<"/">>, <<"_">>, [{foo, bar}]}],
    ?assertEqual(Expected, Result).

compile_routes5_test() ->
    Config = [#{<<"pre">> => [{auth, verify}],
               <<"handle">> => {foo, bar}}],
    Expected = [{<<"/">>, <<"_">>, [{auth, verify}, {foo, bar}]}],
    Result = compile_routes(Config),
    ?assertEqual(Expected, Result).

compile_routes6_test() ->
    Config = [#{<<"path">> => <<"/admin">>,
                <<"handle">> => [#{<<"path">> => <<"/login">>,
                                  <<"method">> => <<"POST">>,
                                  <<"handle">> => {admin, login}},
                                 #{<<"path">> => <<"/user">>,
                                   <<"handle">> => [#{<<"method">> => <<"GET">>, <<"handle">> => {admin, get_user}},
                                                    #{<<"method">> => <<"POST">>, <<"handle">> => {admin, new_user}},
                                                    #{<<"method">> => <<"DELETE">>, <<"handle">> => {admin, delete_user}},
                                                    #{<<"method">> => <<"PUT">>, <<"handle">> => {admin, edit_user}}]}]}],

    Expected = [{<<"/admin/login">>, <<"POST">>, [{admin, login}]},
                {<<"/admin/user">>, <<"GET">>, [{admin, get_user}]},
                {<<"/admin/user">>, <<"POST">>, [{admin, new_user}]},
                {<<"/admin/user">>, <<"DELETE">>, [{admin, delete_user}]},
                {<<"/admin/user">>, <<"PUT">>, [{admin, edit_user}]}],
                    Result = compile_routes(Config),
    ?assertEqual(Expected, Result).

%% /admin/login
%% /admin/user
%% - GET
%% - POST
%% - DELETE
%% - PATCH/PUT
compile_routes3_test() ->
    Config = [
        #{
            <<"path">> => <<"/admin">>,
            <<"handle">> => [
                #{
                    <<"path">> => <<"/login">>,
                    <<"handle">> => {admin, login},
                    <<"method">> => <<"POST">> 
                },
                #{
                    <<"path">> => <<"/user">>,
                    <<"handle">> => [
                        #{<<"handle">> => {admin, get_user},
                          <<"method">> => <<"GET">>},
                        #{<<"handle">> => {admin, new_user},
                          <<"method">> => <<"POST">>},
                        #{<<"handle">> => {admin, update_user},
                          <<"method">> => <<"PATCH">>},
                        #{<<"handle">> => {admin, delete_user},
                          <<"method">> => <<"DELETE">>}
                    ]
                }
            ]
        },
        #{<<"path">> => <<"*">>,
          <<"handle">> => {util, not_found}}
    ],

    Expected = [{<<"/admin/login">>, <<"POST">>, [{admin, login}]},
                {<<"/admin/user">>, <<"GET">>, [{admin, get_user}]},
                {<<"/admin/user">>, <<"POST">>, [{admin, new_user}]},
                {<<"/admin/user">>, <<"PATCH">>, [{admin, update_user}]},
                {<<"/admin/user">>, <<"DELETE">>, [{admin, delete_user}]},
                {<<"/*">>, <<"_">>, [{util,not_found}]}],

    Result = compile_routes(Config),
    ?assertEqual(Expected, Result).

compile_routes7_test() ->
    Config = [#{<<"path">> => <<"/todo">>,
       <<"handle">> => [
         #{<<"method">> => <<"POST">>,
           <<"handle">> => {todo_handler, post_todo}},
         #{<<"method">> => <<"DELETE">>,
           <<"handle">> => {todo_handler, delete_todo}}
    ]}],

    Expected = [
        {<<"/todo">>, <<"POST">>, [{todo_handler, post_todo}]},
        {<<"/todo">>, <<"DELETE">>, [{todo_handler, delete_todo}]}
    ],

    Result = compile_routes(Config),
    ?assertEqual(Expected, Result).

compile_routes_atoms_test() ->
    Config = [#{path => <<"foo">>,
                method => <<"GET">>,
                handle => {foo, bar}}],
    Result = compile_routes(Config),
    Expected = [{<<"/foo">>, <<"GET">>, [{foo, bar}]}],
    ?assertEqual(Expected, Result).


compile_routes_atoms_nested_test() ->
    Config = [#{path => <<"/admin">>,
                handle => [#{path => <<"/login">>,
                             method => <<"POST">>,
                             handle => {admin, login}},
                           #{path => <<"/user">>,
                             handle => [#{method => <<"GET">>,
                                          handle => {user, get}},
                                        #{method => <<"PUT">>,
                                          handle => {user, update}},
                                        #{method => <<"DELETE">>,
                                          handle => {user, delete}}]}]}],

    Expected = [{<<"/admin/login">>, <<"POST">>, [{admin, login}]},
                {<<"/admin/user">>, <<"GET">>, [{user, get}]},
                {<<"/admin/user">>, <<"PUT">>, [{user, update}]},
                {<<"/admin/user">>, <<"DELETE">>, [{user, delete}]}],

    Result = compile_routes(Config),
    ?assertEqual(Expected, Result).

compile_routes_tuple_middleware_test() ->
    Config = #{handle => {index_handler, index},
               pre => {hello_mid, pre},
               post => {hello_mid, post}},
    Expected = [{<<"/">>, <<"_">>, [{hello_mid, pre}, {index_handler, index}, {hello_mid, post}]}],
    Result = compile_routes(Config),
    ?assertEqual(Expected, Result).

compile_routes_missing_handle_test() ->
    Config = #{path => <<"/admin">>},
    try compile_routes(Config) of
        _ ->
            throw("This shouldn't happen")
    catch
       _:_ -> ok
    end.

-endif.