src/nine.erl

-module(nine).

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

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

compile(RouteConfig, Router, Generator) ->
    NormalizedRoutes = compile_routes(RouteConfig),
    Forms = Generator:generate(Router, NormalizedRoutes),
    compile_forms(Forms).

compile_forms(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(Config, Context).

do_compile(#{<<"pre">> := PreConfig} = Config, #{<<"pre">> := PreContext} = Context) ->
    do_compile(maps:remove(<<"pre">>, Config), Context#{<<"pre">> => PreContext ++ PreConfig});
do_compile(#{<<"post">> := PostConfig} = Config, #{<<"post">> := PostContext} = Context) ->
    do_compile(maps:remove(<<"post">>, Config), Context#{<<"post">> => PostContext ++ PostConfig});
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 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).

-endif.