├── .gitignore ├── .travis.yml ├── LICENSE.md ├── Makefile ├── README.md ├── rebar.config ├── rebar3 ├── src ├── rooster.app.src ├── rooster.erl ├── rooster_adapter.erl ├── rooster_app.erl ├── rooster_basic_auth.erl ├── rooster_deps.erl ├── rooster_dispatcher.erl ├── rooster_json.erl ├── rooster_middleware.erl ├── rooster_route.erl ├── rooster_state.erl ├── rooster_sup.erl └── rooster_web.erl └── test ├── adapter_test.erl ├── basic_auth_test.erl ├── dispatcher_test.erl ├── json_test.erl ├── middleware_test.erl ├── routes_test.erl └── state_test.erl /.gitignore: -------------------------------------------------------------------------------- 1 | /ebin 2 | /doc 3 | /_test 4 | /_build 5 | /.idea 6 | /.eunit 7 | /docs 8 | .DS_Store 9 | /TEST-*.xml 10 | /deps 11 | /.rebar 12 | *.swp 13 | *.beam 14 | *.dump 15 | middleware_example.erl 16 | route_example.erl 17 | start.sh 18 | app.erl 19 | server_cert.pem 20 | server_key.pem 21 | rebar.lock 22 | rooster.iml 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: erlang 2 | otp_release: 3 | - 20.1 4 | install: "make" 5 | after_success: "make test" 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Felipe Beline Baravieira. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PREFIX:=../ 2 | DEST:=$(PREFIX)$(PROJECT) 3 | 4 | REBAR=./rebar3 5 | 6 | .PHONY: all edoc test clean build_plt dialyzer app 7 | 8 | all: 9 | @$(REBAR) compile 10 | 11 | edoc: all 12 | @$(REBAR) doc 13 | 14 | test: 15 | @rm -rf .eunit 16 | @mkdir -p .eunit 17 | @$(REBAR) eunit 18 | 19 | clean: 20 | @$(REBAR) clean 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rooster [![Build Status](https://travis-ci.org/fbeline/rooster.svg?branch=master)](https://travis-ci.org/fbeline/rooster) 2 | Simplistic REST framework that runs on top of mochiweb. 3 | ## Features 4 | - **Routes** Composable routing system that supports `GET` `POST` `PUT` and `DELETE` http verbs. 5 | - **Middleware**: Functions that have access to the request and the response, intercepting routes before and/or after execution. 6 | - **Basic Authentication**: Authentication module that can be easily integrated with Middleware. 7 | - **HTTPS Support** 8 | 9 | ## Installation 10 | 1) Download and install [rebar3](https://www.rebar3.org/) 11 | 12 | 2) Create a new application using rebar 13 | 14 | 3) Edit the file **rebar.config** and add the following lines inside deps: 15 | 16 | `{deps, [ {rooster, ".*", {git, "git://github.com/fbeline/rooster.git", {branch, "master"}}} ]}.` 17 | 18 | 4) Compile and download dependencies with `rebar3 compile` 19 | 20 | ## Quick start 21 | Create an entry file that will be the initialization module of your application: 22 | 23 | ```Erlang 24 | -module(server). 25 | -export([start/0]). 26 | 27 | start() -> 28 | rooster:start(#{port => 8080}, 29 | #{routes => [hello()]}). 30 | 31 | hello() -> 32 | {'GET', "/hello", fun(_) -> {200, #{message => <<"hello world">>}} end}. 33 | ``` 34 | 35 | Start it using the command: 36 | 37 | ```Bash 38 | erl \ 39 | -pa ebin _build/default/lib/*/ebin \ 40 | -boot start_sasl \ 41 | -s server \ 42 | -s reloader 43 | ``` 44 | 45 | Run `curl localhost:8080/hello` and it should return: 46 | 47 | ```JSON 48 | {"message": "hello world"} 49 | ``` 50 | 51 | ## Routes 52 | Given the following functions, you will find how to define routes using *generic* or *nested* definition. 53 | 54 | ```Erlang 55 | -export([exports/0]). 56 | 57 | % custom header 58 | get_products(_Req) -> 59 | {200, [#{..}, #{..}], [{"custom-header", "foo"}]}. 60 | 61 | % request path param 62 | get_product(#{params := params}) -> 63 | Id = maps:get(id, params), 64 | {200, #{id => Id, price => 8000}}. 65 | 66 | % request payload 67 | save_product(#{body := Body}) -> 68 | {201, Body}. 69 | ``` 70 | Is important to note that the function **must** have one parameter, that will contain the request information. 71 | 72 | ### Generic definition 73 | The simplest way of defining routes. 74 | 75 | ```Erlang 76 | exports() -> 77 | [{'GET', "/products", fun get_products/1, [auth]}, 78 | {'POST', "/products", fun save_product/1, [auth, admin]} 79 | {'GET', "/products/:id", fun get_product/1, [auth]}]. 80 | ``` 81 | 82 | The **exports** method will provide the list of available endpoints that this module contains. Each tuple should have `{HTTP verb, route path, route handler, list of middleware}`, the list of middleware is not a required parameter as a specific route may use none. 83 | 84 | ### Nested definition 85 | For routes that gonna share a specific root path and or middleware, declaring routes in a nested way should be the proper solution. 86 | 87 | ```Erlang 88 | exports() -> 89 | [{"/products", [auth], 90 | [{'GET', fun get_products/1}, 91 | {'POST', fun save_product/1, [admin]} 92 | {'GET', "/:id", fun get_product/1}]}]. 93 | ``` 94 | The nested definition should fit on the specification: 95 | 96 | ``` 97 | {root path, list of middleware, 98 | [{HTTP verb, *nested path*, route handler, *list of middleware*}]} 99 | ``` 100 | Ps: The parameters surround by * are not required. 101 | 102 | ### Request 103 | The request that will be passed to the route handlers is a map as the one bellow: 104 | 105 | ```erlang 106 | #{path => ..., 107 | method => ..., 108 | headers => ..., 109 | body => ..., 110 | qs => ..., 111 | params => ..., 112 | cookies => ..., 113 | authorization => ...} 114 | ``` 115 | 116 | ## Middleware 117 | 118 | The middleware map can have both `leave` and `enter` keys. The `enter` function will have access to the request information and will be able to change it, the `leave` function will have access to the response and will be able to change it as well. 119 | At any moment that a middleware returns `{break, {status, response}}` the chain of execution will terminate and the `response` will be evaluated as the request result. 120 | 121 | ![middleware](https://user-images.githubusercontent.com/5730881/32140052-75ae38aa-bc3a-11e7-9f54-855b96390bd9.png) 122 | 123 | ### CORS 124 | Simple example using a middleware that intercepts the route handler response and 125 | add to it custom headers. 126 | 127 | ```Erlang 128 | -export([cors/0]). 129 | 130 | access_control() -> 131 | [{"access-control-allow-methods", "*"}, 132 | {"access-control-allow-headers", "*"}, 133 | {"access-control-allow-origin", "*"}]. 134 | 135 | cors() -> 136 | #{name => cors, 137 | leave => fun({Status, Resp, Headers}) -> {Status, Resp, Headers ++ access_control()} end}. 138 | ``` 139 | 140 | ### Basic authentication 141 | Intercepts the http request before the route handler executes and returns `403` if 142 | credentials do not match. 143 | 144 | ```erlang 145 | -export([auth/0]). 146 | 147 | basic_auth(#{authorization := Auth} = Req) -> 148 | Authorizated = rooster_basic_auth:is_authorized(Auth, {"admin", "admin"}), 149 | case Authorizated of 150 | true -> 151 | Req; 152 | _ -> 153 | {break, {403, #{reason => <<"Access Forbidden">>}}} 154 | end. 155 | 156 | auth() -> 157 | #{name => auth, 158 | enter => fun basic_auth/1}. 159 | ``` 160 | 161 | ## SSL 162 | After generating the SSL certificate for your domain, everything that needs to be done is to pass some extra parameters for the server configuration map: (**ssl** and **ssl_opts**). 163 | 164 | ```Erlang 165 | #{port => 8080, 166 | ssl => {ssl, true}, 167 | ssl_opts => {ssl_opts, [{certfile, "{PATH}/server_cert.pem"}, 168 | {keyfile, "{PATH}/server_key.pem"}]}} 169 | ``` 170 | 171 | ## Dependencies 172 | - mochiweb: HTTP server 173 | - jsx: JSON parser 174 | 175 | ## License 176 | MIT 177 | -------------------------------------------------------------------------------- /rebar.config: -------------------------------------------------------------------------------- 1 | %% -*- erlang -*- 2 | {erl_opts, [debug_info, {i, "include"}]}. 3 | {deps, [ 4 | {mochiweb, {git, "https://github.com/mochi/mochiweb.git", {branch, "v2.16.0"}}}, 5 | {jsx, {git, "https://github.com/talentdeficit/jsx.git", {branch, "v2.8.0"}}}]}. 6 | {cover_enabled, true}. 7 | {eunit_opts, [verbose, {report,{eunit_surefire,[{dir,"."}]}}]}. 8 | -------------------------------------------------------------------------------- /rebar3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fbeline/rooster/e8f4d4f1fe2fd901371361dd00f0cb64515424b0/rebar3 -------------------------------------------------------------------------------- /src/rooster.app.src: -------------------------------------------------------------------------------- 1 | %% -*- erlang -*-20rooster_adap%% ===============ter:server_response 2 | {application, rooster, 3 | [{description, "Rest framework built on top of mochiweb"}, 4 | {vsn, "0.1"}, 5 | {modules, [dispatcher, 6 | rooster, 7 | rooster_app, 8 | rooster_json, 9 | rooster_state, 10 | rooster_middleware, 11 | rooster_route, 12 | rooster_dispatcher, 13 | rooster_sup, 14 | rooster_web, 15 | rooster_deps, 16 | rooster_basic_auth, 17 | rooster_adapter]}, 18 | {registered, []}, 19 | {mod, {'rooster_app', []}}, 20 | {env, []}, 21 | {applications, [kernel, stdlib, crypto]}]}. -------------------------------------------------------------------------------- /src/rooster.erl: -------------------------------------------------------------------------------- 1 | -module(rooster). 2 | 3 | -export([stop/0, start/2]). 4 | 5 | -type route() :: {atom(), string(), any(), list(map())}. 6 | -type config() :: #{ip => {integer(), integer(), integer(), integer()}, 7 | port => integer(), 8 | static_path => list(string()), 9 | ssl => any(), 10 | ssl_opts => any()}. 11 | 12 | -type state() :: #{routes => list(route()), 13 | middleware => list(map())}. 14 | 15 | -spec ensure_started(atom()) -> ok. 16 | ensure_started(App) -> 17 | case application:start(App) of 18 | ok -> 19 | ok; 20 | {error, {already_started, App}} -> 21 | ok 22 | end. 23 | 24 | -spec start(config(), state()) -> 'ignore' | {'error', _} | {'ok', pid()}. 25 | start(SrvConf, State) -> 26 | ensure_started(crypto), 27 | rooster_state:start_link([SrvConf, State]), 28 | application:start(rooster). 29 | 30 | -spec stop() -> ok. 31 | stop() -> 32 | application:stop(rooster). -------------------------------------------------------------------------------- /src/rooster_adapter.erl: -------------------------------------------------------------------------------- 1 | -module(rooster_adapter). 2 | 3 | -export([config/1, state/1, middleware/1, route_response/1, server_response/1, request/1, nested_route/1, with_middleware/1]). 4 | 5 | -ifdef(TEST). 6 | -compile(export_all). 7 | -endif. 8 | 9 | base_headers() -> 10 | [{"Content-type", "application/json"}]. 11 | 12 | config(Conf) -> 13 | Default = #{ip => {0, 0, 0, 0}, 14 | port => 8080, 15 | static_path => ["priv", "www"], 16 | ssl => {ssl, false}, 17 | ssl_opts => {ssl_opts, []}}, 18 | maps:merge(Default, Conf). 19 | 20 | state(State) -> 21 | Default = #{routes => [], 22 | middleware => []}, 23 | maps:merge(Default, State). 24 | 25 | middleware(Middleware) -> 26 | Default = #{name => default, 27 | enter => fun(ReqResp) -> ReqResp end, 28 | leave => fun(ReqResp) -> ReqResp end}, 29 | maps:merge(Default, Middleware). 30 | 31 | server_response({Status, Response, Header}) -> 32 | Headers = base_headers() ++ Header, 33 | {Status, Headers, rooster_json:encode(Response)}. 34 | 35 | route_response({Status, Resp}) -> 36 | {Status, Resp, []}; 37 | route_response(Response) -> 38 | Response. 39 | 40 | request(Req) -> 41 | #{path => Req:get(path), 42 | method => Req:get(method), 43 | headers => Req:get(headers), 44 | body => rooster_json:decode(Req:recv_body()), 45 | qs => Req:parse_qs(), 46 | cookies => Req:parse_cookie(), 47 | params => [], 48 | authorization => Req:get_header_value('Authorization')}. 49 | 50 | nested_route([]) -> []; 51 | nested_route([{Method, Path, Fn, Middleware}|T]) -> 52 | [{Method, Path, Fn, Middleware}] ++ nested_route(T); 53 | nested_route([{Method, Path, Fn}|T]) when erlang:is_function(Fn) =:= true -> 54 | [{Method, Path, Fn, []}] ++ nested_route(T); 55 | nested_route([{Method, Fn, Middleware}|T]) -> 56 | [{Method, "", Fn, Middleware}] ++ nested_route(T); 57 | nested_route([{Method, Fn}|T]) -> 58 | [{Method, "", Fn, []}] ++ nested_route(T). 59 | 60 | with_middleware({Method, Path, Fn}) -> 61 | {Method, Path, Fn, []}; 62 | with_middleware(Route) -> Route. 63 | -------------------------------------------------------------------------------- /src/rooster_app.erl: -------------------------------------------------------------------------------- 1 | -module(rooster_app). 2 | -behaviour(application). 3 | 4 | -export([start/2, stop/1]). 5 | 6 | start(_Type, _StartArgs) -> 7 | Options = rooster_state:get(), 8 | rooster_sup:start_link(Options). 9 | 10 | stop(_State) -> 11 | ok. -------------------------------------------------------------------------------- /src/rooster_basic_auth.erl: -------------------------------------------------------------------------------- 1 | -module(rooster_basic_auth). 2 | 3 | -export([is_authorized/2, parse_credentials/1]). 4 | 5 | -type credentials() :: {string(), string()}. 6 | 7 | -spec is_authorized(string(), credentials()) -> true | false. 8 | is_authorized(Auth, Credentials) -> 9 | try 10 | "Basic" ++ EncodedCredentials = Auth, 11 | RCredentials = parse_credentials(EncodedCredentials), 12 | RCredentials =:= Credentials 13 | catch 14 | _:_ -> 15 | false 16 | end. 17 | 18 | -spec parse_credentials(string()) -> credentials() | malformed_credentials. 19 | parse_credentials(EncodedCredentials) -> 20 | Credentials = base64:decode_to_string(EncodedCredentials), 21 | case string:tokens(Credentials, ":") of 22 | [Username, Password] -> {Username, Password}; 23 | _ -> malformed_credentials 24 | end. -------------------------------------------------------------------------------- /src/rooster_deps.erl: -------------------------------------------------------------------------------- 1 | -module(rooster_deps). 2 | 3 | -export([ensure/0, ensure/1]). 4 | -export([get_base_dir/0, get_base_dir/1]). 5 | -export([local_path/1, local_path/2]). 6 | -export([deps_on_path/0, new_siblings/1]). 7 | 8 | deps_on_path() -> 9 | F = fun(X, Acc) -> 10 | ProjDir = filename:dirname(X), 11 | case {filename:basename(X), 12 | filename:basename(filename:dirname(ProjDir))} of 13 | {"ebin", "deps"} -> 14 | [filename:basename(ProjDir) | Acc]; 15 | _ -> 16 | Acc 17 | end 18 | end, 19 | ordsets:from_list(lists:foldl(F, [], code:get_path())). 20 | 21 | new_siblings(Module) -> 22 | Existing = deps_on_path(), 23 | SiblingEbin = filelib:wildcard(local_path(["deps", "*", "ebin"], Module)), 24 | Siblings = [filename:dirname(X) || X <- SiblingEbin, 25 | ordsets:is_element( 26 | filename:basename(filename:dirname(X)), 27 | Existing) =:= false], 28 | lists:filter(fun filelib:is_dir/1, 29 | lists:append([[filename:join([X, "ebin"]), 30 | filename:join([X, "include"])] || 31 | X <- Siblings])). 32 | 33 | ensure(Module) -> 34 | code:add_paths(new_siblings(Module)), 35 | code:clash(), 36 | ok. 37 | 38 | ensure() -> 39 | ensure(?MODULE). 40 | 41 | get_base_dir(Module) -> 42 | {file, Here} = code:is_loaded(Module), 43 | filename:dirname(filename:dirname(Here)). 44 | 45 | get_base_dir() -> 46 | get_base_dir(?MODULE). 47 | 48 | local_path(Components, Module) -> 49 | filename:join([get_base_dir(Module) | Components]). 50 | 51 | local_path(Components) -> 52 | local_path(Components, ?MODULE). -------------------------------------------------------------------------------- /src/rooster_dispatcher.erl: -------------------------------------------------------------------------------- 1 | -module(rooster_dispatcher). 2 | 3 | -export([match_route/1]). 4 | 5 | -ifdef(TEST). 6 | -compile(export_all). 7 | -endif. 8 | 9 | match_route(#{path := Path, method := Method} = Req) -> 10 | match_route(Path, Method, Req, rooster_route:get()). 11 | 12 | match_route(_, _, _, []) -> {404, #{message => <<"Not found">>}, []}; 13 | match_route(RequestedRoute, Method, Req, [{Method, Route, Fn, Middleware}|T]) -> 14 | {ContextTokens, RequestTokens} = {parse_route(Route), parse_route(RequestedRoute)}, 15 | case path_params(ContextTokens, RequestTokens) of 16 | {ok, Params} -> handle_request(Req#{params := Params}, Fn, Middleware); 17 | {not_match, _} -> match_route(RequestedRoute, Method, Req, T) 18 | end; 19 | match_route(RequestedRoute, Method, Req, [_|T]) -> match_route(RequestedRoute, Method, Req, T). 20 | 21 | handle_request(Request, Fn, Middleware) -> 22 | Res = rooster_middleware:enter(Request, Middleware), 23 | call_route(Res, Fn, Middleware). 24 | 25 | call_route({break, Resp}, _, _) -> rooster_adapter:route_response(Resp); 26 | call_route({ok, Req}, Fn, Middleware)-> 27 | RouteResponse = rooster_adapter:route_response(Fn(Req)), 28 | {_, Response} = rooster_middleware:leave(RouteResponse, Middleware), 29 | Response. 30 | 31 | parse_route(Route) -> 32 | [RouteWithoutQueryParams | _] = string:tokens(Route, "?"), 33 | RouteTokens = string:tokens(RouteWithoutQueryParams, "/"), 34 | RouteTokens. 35 | 36 | path_params(ContextTokens, RequestedTokens) -> 37 | if erlang:length(ContextTokens) =:= erlang:length(RequestedTokens) -> path_params(ContextTokens, RequestedTokens, #{}); 38 | true -> {not_match, #{}} 39 | end. 40 | 41 | path_params([], [], Acc) -> {ok, Acc}; 42 | path_params([Token|T1], [Token|T2], Acc) -> path_params(T1, T2, Acc); 43 | path_params([Context|T1], [Requested|T2], Acc) -> 44 | IsPathParam = string:str(Context, ":") =:= 1, 45 | if IsPathParam -> path_params(T1, T2, new_param(Acc, {Context, Requested})); 46 | true -> {not_match, #{}} 47 | end. 48 | 49 | new_param(Params, {Key, Value}) -> 50 | Params#{erlang:list_to_atom(string:slice(Key, 1)) => Value}. 51 | -------------------------------------------------------------------------------- /src/rooster_json.erl: -------------------------------------------------------------------------------- 1 | -module(rooster_json). 2 | 3 | -export([encode/1, decode/1]). 4 | 5 | -spec encode(any()) -> string(). 6 | encode(Term) -> 7 | jsx:encode(Term). 8 | 9 | -spec decode(string()) -> any(). 10 | decode(Term) -> 11 | Data = case Term of 12 | undefined -> erlang:list_to_binary("{}"); 13 | Bin -> Bin 14 | end, 15 | jsx:decode(Data, [{labels, atom}, return_maps]). -------------------------------------------------------------------------------- /src/rooster_middleware.erl: -------------------------------------------------------------------------------- 1 | -module(rooster_middleware). 2 | -behaviour(gen_server). 3 | 4 | -export([start_link/1]). 5 | -export([init/1, handle_call/3, handle_cast/2, 6 | handle_info/2, terminate/2, code_change/3, 7 | enter/2, leave/2]). 8 | 9 | %%%=================================================================== 10 | %%% API 11 | %%%=================================================================== 12 | start_link(State) -> 13 | gen_server:start_link({local, ?MODULE}, ?MODULE, State, []). 14 | 15 | enter(ReqResp, Names) -> dispatch(ReqResp, Names, enter). 16 | 17 | leave(ReqResp, Names) -> dispatch(ReqResp, Names, leave). 18 | 19 | %%%=================================================================== 20 | %%% gen_server callbacks 21 | %%%=================================================================== 22 | init(Env) -> 23 | InternalState = lists:map(fun rooster_adapter:middleware/1, Env), 24 | {ok, InternalState}. 25 | 26 | handle_call(get_state, _From, State) -> 27 | {reply, State, State}. 28 | 29 | handle_cast(_Request, State) -> 30 | {noreply, State}. 31 | 32 | handle_info(_Info, State) -> 33 | {noreply, State}. 34 | 35 | terminate(_Reason, _State) -> 36 | ok. 37 | 38 | code_change(_OldVsn, State, _Extra) -> 39 | {ok, State}. 40 | 41 | %%%=================================================================== 42 | %%% Internal functions 43 | %%%=================================================================== 44 | dispatch(ReqResp, Names, Action) -> 45 | State = gen_server:call(?MODULE, get_state), 46 | Middleware = match_middleware(Names, State), 47 | middleware_reduce(ReqResp, Middleware, Action). 48 | 49 | match_middleware(Names, Middleware) -> match_middleware(Names, Middleware, []). 50 | match_middleware(_, [], Acc) -> Acc; 51 | match_middleware(Names, [Middleware | T], Acc) -> 52 | Match = lists:filter(fun(Name) -> Name =:= maps:get(name, Middleware) end, Names), 53 | add_middleware(Match, {Names, T, Middleware, Acc}). 54 | 55 | add_middleware([], {Names, T, _, Acc}) -> 56 | match_middleware(Names, T, Acc); 57 | add_middleware(_, {Names, T, Middleware, Acc}) -> 58 | match_middleware(Names, T, [Middleware] ++ Acc). 59 | 60 | middleware_reduce(ReqResp, [], _) -> {ok, ReqResp}; 61 | middleware_reduce({break, Resp}, _, _) -> {break, Resp}; 62 | middleware_reduce(ReqResp, [Middleware | T], Action) -> 63 | Fun = maps:get(Action, Middleware), 64 | middleware_reduce(Fun(ReqResp), T, Action). -------------------------------------------------------------------------------- /src/rooster_route.erl: -------------------------------------------------------------------------------- 1 | -module(rooster_route). 2 | 3 | -behaviour(gen_server). 4 | 5 | -export([start_link/1, get/0]). 6 | -export([init/1, handle_call/3, handle_cast/2, 7 | handle_info/2, terminate/2, code_change/3]). 8 | 9 | -ifdef(TEST). 10 | -compile(export_all). 11 | -endif. 12 | 13 | %% =============== 14 | %%% API 15 | %% =============== 16 | start_link(State) -> 17 | gen_server:start_link({local, ?MODULE}, ?MODULE, State, []). 18 | 19 | get() -> 20 | gen_server:call(?MODULE, get_state). 21 | 22 | %% =============== 23 | %%% gen_server callbacks 24 | %% =============== 25 | init(Env) -> 26 | {ok, routes(Env)}. 27 | 28 | handle_call(get_state, _From, State) -> 29 | {reply, State, State}. 30 | 31 | handle_cast(_Request, State) -> 32 | {noreply, State}. 33 | 34 | handle_info(_Info, State) -> 35 | {noreply, State}. 36 | 37 | terminate(_Reason, _State) -> 38 | ok. 39 | 40 | code_change(_OldVsn, State, _Extra) -> 41 | {ok, State}. 42 | 43 | %% =============== 44 | %%% Internal functions 45 | %% =============== 46 | routes(Routes) -> 47 | sort(adapt_nested(lists:flatten(Routes), [])). 48 | 49 | adapt_nested([], Acc) -> Acc; 50 | adapt_nested([{Path, Middleware, Nested}|T], Acc) 51 | when erlang:is_list(Middleware), erlang:is_list(Nested) -> 52 | adapt_nested(T, Acc ++ nested(Path, Middleware, rooster_adapter:nested_route(Nested))); 53 | adapt_nested([Route|T], Acc) -> 54 | adapt_nested(T, Acc ++ [rooster_adapter:with_middleware(Route)]). 55 | 56 | nested(_, _, []) -> []; 57 | nested(Path, Middleware, [{Method, NPath, Fn, NMiddleware}|T]) -> 58 | [{Method, Path ++ NPath, Fn, Middleware ++ NMiddleware}] ++ nested(Path, Middleware, T). 59 | 60 | sort(Routes) -> sort(Routes, [], []). 61 | 62 | sort([], LF, LR) -> LF ++ LR; 63 | sort([{Method, Path, Fn, Middleware}|T], LF, LR) -> 64 | case string:str(Path, ":") of 65 | 0 -> sort(T, LF ++ [{Method, Path, Fn, Middleware}], LR); 66 | _ -> sort(T, LF, LR ++ [{Method, Path, Fn, Middleware}]) 67 | end. -------------------------------------------------------------------------------- /src/rooster_state.erl: -------------------------------------------------------------------------------- 1 | -module(rooster_state). 2 | 3 | -behaviour(gen_server). 4 | 5 | -export([start_link/1, stop/0, init/1, get/0]). 6 | -export([handle_call/3, handle_cast/2, terminate/2, handle_info/2, code_change/3]). 7 | 8 | %% =============== 9 | %% Public API 10 | %% =============== 11 | start_link(State) -> 12 | gen_server:start_link({local, ?MODULE}, ?MODULE, State, []). 13 | 14 | get() -> 15 | gen_server:call(?MODULE, get_state). 16 | 17 | stop() -> 18 | gen_server:cast(?MODULE, stop). 19 | 20 | %% =============== 21 | %% Server API 22 | %% =============== 23 | handle_cast(stop, Env) -> 24 | {stop, normal, Env}; 25 | 26 | handle_cast({set_state, State}, _State) -> 27 | {noreply, State}. 28 | 29 | handle_call(get_state, _From, State) -> 30 | {reply, State, State}. 31 | 32 | %% =============== 33 | %% Server callbacks 34 | %% =============== 35 | handle_info({'EXIT', _Pid, _Reason}, State) -> 36 | {noreply, State}. 37 | 38 | code_change(_OldVsn, State, _Extra) -> 39 | {ok, State}. 40 | 41 | init(Env) -> 42 | {ok, Env}. 43 | 44 | terminate(_Reason, _Env) -> 45 | ok. -------------------------------------------------------------------------------- /src/rooster_sup.erl: -------------------------------------------------------------------------------- 1 | -module(rooster_sup). 2 | -behaviour(supervisor). 3 | 4 | -export([start_link/1, upgrade/0]). 5 | -export([init/1]). 6 | 7 | %% =============== 8 | %%% API functions 9 | %% =============== 10 | start_link([SrvConf, State]) -> 11 | ISrvConf = rooster_adapter:config(SrvConf), 12 | supervisor:start_link({local, ?MODULE}, ?MODULE, [ISrvConf, State]). 13 | 14 | %% =============== 15 | %%% Supervisor callbacks 16 | %% =============== 17 | init([Conf, State]) -> 18 | WebSpecs = web_specs(rooster_web, Conf), 19 | MiddlewareSpecs = middleware_specs(State), 20 | RouteSpecs = route_specs(State), 21 | rooster_deps:ensure(), 22 | {ok, {{one_for_one, 10, 10}, [RouteSpecs, MiddlewareSpecs, WebSpecs]}}. 23 | 24 | upgrade() -> 25 | {ok, {_, Specs}} = init([]), 26 | Old = sets:from_list( 27 | [Name || {Name, _, _, _} <- supervisor:which_children(?MODULE)]), 28 | New = sets:from_list([Name || {Name, _, _, _, _, _} <- Specs]), 29 | Kill = sets:subtract(Old, New), 30 | 31 | sets:fold(fun(Id, ok) -> 32 | supervisor:terminate_child(?MODULE, Id), 33 | supervisor:delete_child(?MODULE, Id), 34 | ok 35 | end, ok, Kill), 36 | 37 | [supervisor:start_child(?MODULE, Spec) || Spec <- Specs], 38 | ok. 39 | 40 | %% =============== 41 | %%% Internal functions 42 | %% =============== 43 | web_specs(Mod, #{ip := Ip, port := Port, static_path := Sp, ssl := Ssl, ssl_opts := Ssl_opts}) -> 44 | WebConfig = [{ip, Ip}, 45 | {port, Port}, 46 | {docroot, rooster_deps:local_path(Sp, ?MODULE)}, 47 | Ssl, Ssl_opts], 48 | {Mod, {Mod, start, [WebConfig]}, permanent, 5000, worker, dynamic}. 49 | 50 | middleware_specs(#{middleware := Middleware}) -> 51 | #{id => rooster_middleware, 52 | start => {rooster_middleware, start_link, [Middleware]}, 53 | restart => permanent, 54 | shutdown => brutal_kill, 55 | type => worker, 56 | modules => []}. 57 | 58 | route_specs(#{routes := Routes}) -> 59 | #{id => rooster_route, 60 | start => {rooster_route, start_link, [Routes]}, 61 | restart => permanent, 62 | shutdown => brutal_kill, 63 | type => worker, 64 | modules => []}. 65 | 66 | -------------------------------------------------------------------------------- /src/rooster_web.erl: -------------------------------------------------------------------------------- 1 | -module(rooster_web). 2 | 3 | -export([start/1, stop/0, loop/2]). 4 | 5 | start(Options) -> 6 | {DocRoot, Options1} = get_option(docroot, Options), 7 | Loop = fun(Req) -> ?MODULE:loop(Req, DocRoot) end, 8 | mochiweb_http:start([{name, ?MODULE}, {loop, Loop} | Options1]). 9 | 10 | loop(Req, _DocRoot) -> 11 | try 12 | Response = rooster_dispatcher:match_route(rooster_adapter:request(Req)), 13 | Req:respond(rooster_adapter:server_response(Response)) 14 | catch 15 | Type:What -> 16 | log_error(Type, What), 17 | Req:respond({500, [{"Content-Type", "application/json"}], request_fail_msg()}) 18 | end. 19 | 20 | request_fail_msg() -> 21 | rooster_json:encode(#{message => <<"Internal server error">>}). 22 | 23 | log_error(Type, What) -> 24 | Report = ["web request failed", 25 | {type, Type}, 26 | {what, What}, 27 | {trace, erlang:get_stacktrace()}], 28 | error_logger:error_report(Report). 29 | 30 | get_option(Option, Options) -> 31 | {proplists:get_value(Option, Options), proplists:delete(Option, Options)}. 32 | 33 | stop() -> 34 | mochiweb_http:stop(?MODULE). -------------------------------------------------------------------------------- /test/adapter_test.erl: -------------------------------------------------------------------------------- 1 | -module(adapter_test). 2 | 3 | -include_lib("eunit/include/eunit.hrl"). 4 | 5 | config_adapter_test() -> 6 | Expected = #{ip => {0, 0, 0, 0}, 7 | port => 8080, 8 | static_path => ["priv", "www"], 9 | ssl => {ssl, false}, 10 | ssl_opts => {ssl_opts, []}}, 11 | ?assertEqual(Expected, rooster_adapter:config(#{})). 12 | 13 | state_adapter_test() -> 14 | Expected = #{routes => [], 15 | middleware => []}, 16 | ?assertEqual(Expected, rooster_adapter:state(#{})). 17 | 18 | middleware_test() -> 19 | Result = rooster_adapter:middleware(#{name => m_test}), 20 | ?assertEqual(m_test, maps:get(name, Result)), 21 | ?assert(is_function(maps:get(enter, Result))), 22 | ?assert(is_function(maps:get(leave, Result))). 23 | 24 | server_response_test() -> 25 | Result = rooster_adapter:server_response({200, #{}, [{"authorization","foo"}]}), 26 | ?assertEqual({200, 27 | [{"Content-type", "application/json"}, 28 | {"authorization","foo"}], 29 | <<"{}">>}, Result). 30 | 31 | route_response_test() -> 32 | Result = rooster_adapter:route_response({200, #{}}), 33 | ?assertEqual({200, #{}, []}, Result). 34 | 35 | route_response_with_header_test() -> 36 | Result = rooster_adapter:route_response({200, #{}, [{"foo", "bar"}]}), 37 | ?assertEqual({200, #{}, [{"foo", "bar"}]}, Result). 38 | 39 | base_headers_test() -> 40 | ?assertEqual([{"Content-type", "application/json"}], rooster_adapter:base_headers()). 41 | 42 | nested_route_test() -> 43 | Fn = fun() -> 1 end, 44 | Nested = [{'GET', Fn, [test]}, 45 | {'POST', Fn}, 46 | {'GET', "/permissions", Fn}, 47 | {'GET', "/health", Fn, [test]}], 48 | Expected = [{'GET', "", Fn, [test]}, 49 | {'POST',"", Fn, []}, 50 | {'GET', "/permissions", Fn, []}, 51 | {'GET', "/health", Fn, [test]}], 52 | Result = rooster_adapter:nested_route(Nested), 53 | ?assertEqual(Expected, Result). 54 | 55 | with_middleware_test() -> 56 | Route = {'GET', "/foo", foo}, 57 | Result = rooster_adapter:with_middleware(Route), 58 | ?assertEqual({'GET', "/foo", foo, []}, Result). 59 | 60 | with_middleware_valid_test() -> 61 | Route = {'GET', "/foo", foo, []}, 62 | Result = rooster_adapter:with_middleware(Route), 63 | ?assertEqual(Route, Result). 64 | -------------------------------------------------------------------------------- /test/basic_auth_test.erl: -------------------------------------------------------------------------------- 1 | -module(basic_auth_test). 2 | 3 | -include_lib("eunit/include/eunit.hrl"). 4 | 5 | is_authorized_sanity_test() -> 6 | Auth = "Basic YWRtaW46YWRtaW4=", % admin:admin 7 | Credentials = {"admin", "admin"}, 8 | Resp = rooster_basic_auth:is_authorized(Auth, Credentials), 9 | ?assertEqual(true, Resp). 10 | 11 | is_authorized_bad_credentials_test() -> 12 | Auth = "Basic YWRtaW46YWRtaW4=", % admin:admin 13 | Credentials = {"admin", "123"}, 14 | Resp = rooster_basic_auth:is_authorized(Auth, Credentials), 15 | ?assertEqual(false, Resp). 16 | 17 | parse_credentials_sanity_credentials_test() -> 18 | Auth = "YWRtaW46YWRtaW4=", 19 | Credentials = {"admin", "admin"}, 20 | Resp = rooster_basic_auth:parse_credentials(Auth), 21 | ?assertEqual(Credentials, Resp). 22 | 23 | parse_credentials_malformed_credentials_test() -> 24 | Auth = "Basic YWRtRtaW4=", 25 | Credentials = {"admin", "admin"}, 26 | Resp = rooster_basic_auth:is_authorized(Auth, Credentials), 27 | ?assertEqual(false, Resp). 28 | -------------------------------------------------------------------------------- /test/dispatcher_test.erl: -------------------------------------------------------------------------------- 1 | -module(dispatcher_test). 2 | -include_lib("eunit/include/eunit.hrl"). 3 | 4 | path_params_valid_test() -> 5 | RequestedRoute = ["products", "save"], 6 | Route = ["products", "save"], 7 | ?assertEqual({ok, #{}}, 8 | rooster_dispatcher:path_params(Route, RequestedRoute)). 9 | 10 | path_params_with_values_test() -> 11 | RequestedRoute = ["products", "10", "load"], 12 | Route = ["products", ":id", "load"], 13 | ?assertEqual({ok, #{id => "10"}}, 14 | rooster_dispatcher:path_params(Route, RequestedRoute)). 15 | 16 | path_params_invalid_test() -> 17 | RequestedRoute = ["productz"], 18 | Route = ["products"], 19 | ?assertEqual({not_match, #{}}, 20 | rooster_dispatcher:path_params(Route, RequestedRoute)). 21 | 22 | path_params_wrong_number_of_tokens_test() -> 23 | RequestedRoute = ["products", "10"], 24 | Route = ["products", ":id", "update"], 25 | ?assertEqual({not_match, #{}}, 26 | rooster_dispatcher:path_params(Route, RequestedRoute)). 27 | 28 | parse_route_sanity_test() -> 29 | Tokens = rooster_dispatcher:parse_route("products/test?id=10"), 30 | ?assertEqual(["products", "test"], Tokens). 31 | 32 | parse_route_path_params_test() -> 33 | Tokens = rooster_dispatcher:parse_route("products/10/save/1"), 34 | ?assertEqual(["products", "10", "save", "1"], Tokens). 35 | -------------------------------------------------------------------------------- /test/json_test.erl: -------------------------------------------------------------------------------- 1 | -module(json_test). 2 | 3 | -include_lib("eunit/include/eunit.hrl"). 4 | 5 | encode_test() -> 6 | Expected = <<"{\"foo\":\"bar\"}">>, 7 | Result = rooster_json:encode(#{foo => bar}), 8 | ?assertEqual(Expected, Result). 9 | 10 | decode_test() -> 11 | Expected = #{foo => 42}, 12 | Result = rooster_json:decode(<<"{\"foo\":42}">>), 13 | ?assertEqual(Expected, Result). 14 | -------------------------------------------------------------------------------- /test/middleware_test.erl: -------------------------------------------------------------------------------- 1 | -module(middleware_test). 2 | 3 | -include_lib("eunit/include/eunit.hrl"). 4 | 5 | middleware() -> 6 | [#{name => foo, leave => fun(Resp) -> Resp * 2 end}, 7 | #{name => bar, enter => fun(Req) -> Req * 3 end}, 8 | #{name => baz, enter => fun(Req) -> Req * 2 end, leave => fun(Resp) -> Resp * 2 end}, 9 | #{name => breaker, enter => fun(_Req) -> {break, {404, #{}}} end}]. 10 | 11 | server_start_test() -> 12 | Middleware = middleware(), 13 | {ok, Pid} = rooster_middleware:start_link(Middleware), 14 | State = gen_server:call(Pid, get_state), 15 | Expected = lists:map(fun rooster_adapter:middleware/1, Middleware), 16 | ?assertEqual(Expected, State). 17 | 18 | leave_test() -> 19 | rooster_middleware:start_link(middleware()), 20 | Resp = rooster_middleware:leave(1, [foo]), 21 | ?assertEqual({ok, 2}, Resp). 22 | 23 | enter_test() -> 24 | rooster_middleware:start_link(middleware()), 25 | Req = rooster_middleware:enter(1, [bar]), 26 | ?assertEqual({ok, 3}, Req). 27 | 28 | multiple_middleware_enter_test() -> 29 | rooster_middleware:start_link(middleware()), 30 | Req = rooster_middleware:enter(1, [baz, bar]), 31 | ?assertEqual({ok, 6}, Req). 32 | 33 | multiple_middleware_leave_test() -> 34 | rooster_middleware:start_link(middleware()), 35 | Resp = rooster_middleware:leave(1, [baz, foo]), 36 | ?assertEqual({ok, 4}, Resp). 37 | 38 | break_middleware_test() -> 39 | Resp = rooster_middleware:enter(1, [breaker, foo]), 40 | ?assertEqual({break, {404, #{}}}, Resp). 41 | 42 | -------------------------------------------------------------------------------- /test/routes_test.erl: -------------------------------------------------------------------------------- 1 | -module(routes_test). 2 | 3 | -include_lib("eunit/include/eunit.hrl"). 4 | 5 | routes(Fn) -> 6 | [{"/account/:id", [auth], 7 | [{'GET', Fn , [test]}, 8 | {'POST', Fn}, 9 | {'GET', "/permissions", Fn}]}]. 10 | 11 | mixed_routes(Fn) -> 12 | routes(Fn) ++ [{'GET', "/health", Fn}]. 13 | 14 | complete_routes(Fn) -> 15 | {"/account/:id", [auth], 16 | [{'GET', "/foo", Fn, [test]}, 17 | {'POST', "/bar", Fn, [test]}]}. 18 | 19 | expected_routes(Fn) -> 20 | [{'GET', "/account/:id", Fn, [auth, test]}, 21 | {'POST', "/account/:id", Fn, [auth]}, 22 | {'GET', "/account/:id/permissions", Fn, [auth]}]. 23 | 24 | expected_mixed_routes(Fn) -> 25 | [{'GET', "/health", Fn, []}] ++ expected_routes(Fn). 26 | 27 | expected_complete_routes(Fn) -> 28 | [{'GET', "/account/:id/foo", Fn, [auth, test]}, 29 | {'POST', "/account/:id/bar", Fn, [auth, test]}]. 30 | 31 | generic_routes() -> 32 | [{'GET', "/account/:id", fn, []}, 33 | {'GET', "/account/foo", fn, []}, 34 | {'POST', "/account/save", fn, []}, 35 | {'POST', "/account/:id/status", fn, []}]. 36 | 37 | expected_sorted_routes() -> 38 | [{'GET', "/account/foo", fn, []}, 39 | {'POST', "/account/save", fn, []}, 40 | {'GET', "/account/:id", fn, []}, 41 | {'POST', "/account/:id/status", fn, []}]. 42 | 43 | start_test() -> 44 | Fn = fun() -> foo end, 45 | rooster_route:start_link(routes(Fn)), 46 | Routes = rooster_route:get(), 47 | gen_server:stop(rooster_route), 48 | ?assert(erlang:is_list(Routes)). 49 | 50 | routes_sanity_test() -> 51 | Fn = fun() -> foo end, 52 | rooster_route:start_link(routes(Fn)), 53 | Routes = rooster_route:get(), 54 | gen_server:stop(rooster_route), 55 | ?assertEqual(expected_routes(Fn), Routes). 56 | 57 | mixed_routes_test() -> 58 | Fn = fun() -> foo end, 59 | rooster_route:terminate(nil, nil), 60 | rooster_route:start_link(mixed_routes(Fn)), 61 | Routes = rooster_route:get(), 62 | ?assertEqual(expected_mixed_routes(Fn), Routes). 63 | 64 | nested_test() -> 65 | Fn = fun() -> foo end, 66 | {Path, Middleware, Nested} = complete_routes(Fn), 67 | Routes = rooster_route:nested(Path, Middleware, Nested), 68 | ?assertEqual(expected_complete_routes(Fn), Routes). 69 | 70 | sort_test() -> 71 | Sorted = rooster_route:sort(generic_routes()), 72 | ?assertEqual(expected_sorted_routes(), Sorted). 73 | -------------------------------------------------------------------------------- /test/state_test.erl: -------------------------------------------------------------------------------- 1 | -module(state_test). 2 | 3 | -include_lib("eunit/include/eunit.hrl"). 4 | 5 | simple_test() -> 6 | rooster_state:start_link(#{}), 7 | State = gen_server:call(rooster_state, get_state), 8 | rooster_state:stop(), 9 | ?assertEqual(#{}, State). 10 | 11 | set_test() -> 12 | rooster_state:start_link(#{}), 13 | gen_server:cast(rooster_state, {set_state, #{middleware => [foo]}}), 14 | State = gen_server:call(rooster_state, get_state), 15 | rooster_state:stop(), 16 | ?assertEqual(#{middleware => [foo]}, State). 17 | --------------------------------------------------------------------------------