Maxim Sokhatsky 11 years ago
commit
34dc8cbd61
6 changed files with 290 additions and 0 deletions
  1. 32 0
      README.md
  2. 9 0
      src/rest.app.src
  3. 131 0
      src/rest.erl
  4. 6 0
      src/rest_app.erl
  5. 101 0
      src/rest_cowboy.erl
  6. 11 0
      src/rest_sup.erl

+ 32 - 0
README.md

@@ -0,0 +1,32 @@
+REST: N2O separate endpoint
+===========================
+
+Usage
+-----
+
+```erlang
+{"/rest/:resource", rest_cowboy, []},
+{"/rest/:resource/:id", rest_cowboy, []},
+```
+
+Module
+------
+
+```erlang
+-module(users).
+-behaviour(rest).
+-compile({parse_transform, rest}).
+-include("users.hrl").
+-export([init/0, populate/1, exists/1, get/0, get/1, post/1, delete/1]).
+-rest_record(user).
+
+init() -> ets:new(users, [public, named_table, {keypos, #user.id}]).
+populate(Users) -> ets:insert(users, Users).
+exists(Id) -> ets:member(users, wf:to_list(Id)).
+get() -> ets:tab2list(users).
+get(Id) -> [User] = ets:lookup(users, wf:to_list(Id)), User.
+delete(Id) -> ets:delete(users, wf:to_list(Id)).
+post(#user{} = User) -> ets:insert(users, User);
+post(Data) -> post(from_json(Data, #user{})).
+```
+

+ 9 - 0
src/rest.app.src

@@ -0,0 +1,9 @@
+{application, rest, [
+    {description,  "REST SXC"},
+    {vsn,          "3.0"},
+    {applications, [kernel, stdlib, cowboy, ranch, gproc, mimetypes, erlydtl]},
+    {modules, []},
+    {registered,   []},
+    {mod, { rest_app, []}},
+    {env, []}
+]}.

+ 131 - 0
src/rest.erl

@@ -0,0 +1,131 @@
+-module(rest).
+-author('Dmitry Bushmelev').
+-export([behaviour_info/1, parse_transform/2, generate_to_json/3, generate_from_json/3, from_json/1, to_json/1]).
+
+behaviour_info(callbacks) -> [{exists, 1}, {get, 0}, {get, 1}, {post, 1}, {delete, 1}, {from_json, 2}, {to_json, 1}];
+behaviour_info(_) -> undefined.
+
+parse_transform(Forms, _Options) ->
+%    io:format("~p~n", [Forms]),
+    RecordName = rest_record(Forms),
+    RecordFields = record_fields(RecordName, Forms),
+    Forms1 = generate({from_json, 2}, RecordName, RecordFields, Forms),
+    Forms2 = generate({to_json, 1}, RecordName, RecordFields, Forms1),
+%    io:format("~p~n", [Forms2]),
+    Forms2.
+
+rest_record([]) -> [];
+rest_record([{attribute, _, rest_record, RecordName} | _Forms]) -> RecordName;
+rest_record([_ | Forms]) -> rest_record(Forms).
+
+record_field({record_field, _, {atom, _, Field}   }) -> Field;
+record_field({record_field, _, {atom, _, Field}, _}) -> Field.
+
+record_fields(RecordName, [{attribute, _, record, {RecordName, Fields}} | _Forms]) ->
+    [record_field(Field) || Field <- Fields];
+record_fields(RecordName, [_ | Forms]) -> record_fields(RecordName, Forms).
+
+last_export_line(Exports) -> [{_, Line, _, _} | _] = lists:reverse(Exports), Line.
+
+generate({FunName, _Arity} = Fun, Record, Fields, Forms) ->
+    Exports = lists:filter(fun({attribute, _, export, _}) -> true; (_) -> false end, Forms),
+    case exported(Fun, Exports) of
+        true  -> Forms;
+        false ->
+            Line = last_export_line(Exports),
+            Gen = list_to_atom("generate_" ++ atom_to_list(FunName)),
+            lists:flatten([?MODULE:Gen(export(Form, Fun, Line), Record, Fields) || Form <- Forms])
+    end.
+
+exported(Fun, Exports) -> lists:member(Fun, lists:flatten([E || {attribute, _, export, E} <- Exports])).
+
+field_var(Field) -> list_to_atom("V_" ++ atom_to_list(Field)).
+
+from_json_prelude(Line) ->
+    {clause, Line,
+     [{nil, Line}, {var, Line, 'Acc'}],
+     [],
+     [{var, Line, 'Acc'}]}.
+
+from_json_coda(Line) ->
+    {clause, Line,
+     [{cons, Line, {var, Line, '_'}, {var, Line, 'Json'}}, {var, Line, 'Acc'}],
+     [],
+     [{call, Line, {atom, Line, from_json}, [{var, Line, 'Json'}, {var, Line, 'Acc'}]}]}.
+
+from_json_clauses(_, _, []) -> [];
+from_json_clauses(Line, Record, [Field | Fields]) ->
+    [{clause, Line,
+      [{cons, Line,
+        {tuple, Line,
+         [{bin, Line,
+           [{bin_element, Line, {string, Line, atom_to_list(Field)}, default, default}]},
+          {var, Line, field_var(Field)}]},
+        {var, Line, 'Json'}},
+       {var, Line, 'Acc'}],
+      [],
+      [{call, Line,
+        {atom, Line, from_json},
+        [{var, Line, 'Json'},
+         {record, Line,
+          {var, Line, 'Acc'},
+          Record,
+          [{record_field, Line,
+            {atom, Line, Field},
+            {call, Line,
+             {remote, Line, {atom, Line, ?MODULE}, {atom, Line, from_json}},
+             [{var, Line, field_var(Field)}]}}]}]}]}
+     | from_json_clauses(Line, Record, Fields)].
+
+generate_from_json({eof, Line}, Record, Fields) ->
+    [{function, Line, from_json, 2,
+      [from_json_prelude(Line)] ++ from_json_clauses(Line, Record, Fields) ++ [from_json_coda(Line)]},
+     {eof, Line + 1}];
+generate_from_json(Form, _, _) -> Form.
+
+export({attribute, LastExportLine, export, Exports}, Fun, LastExportLine) ->
+    {attribute, LastExportLine, export, [Fun | Exports]};
+export(Form, _, _) -> Form.
+
+to_json_cons(Line, []) -> {nil, Line};
+to_json_cons(Line, [Field | Fields]) ->
+    {cons, Line,
+     {tuple, Line,
+      [{atom, Line, Field},
+       {call, Line,
+        {remote, Line, {atom, Line, ?MODULE}, {atom, Line, to_json}},
+        [{var, Line, field_var(Field)}]}]},
+     to_json_cons(Line, Fields)}.
+
+generate_to_json({eof, Line}, Record, Fields) ->
+    [{function, Line, to_json, 1,
+      [{clause, Line,
+        [{record, Line, Record,
+          [{record_field, Line, {atom, Line, F}, {var, Line, field_var(F)}} || F <- Fields]}],
+        [],
+        [to_json_cons(Line, Fields)]}]},
+     {eof, Line + 1}];
+
+generate_to_json(Form, _, _) -> Form.
+
+from_json(<<Data/binary>>) -> binary_to_list(Data);
+from_json({struct, Props}) -> from_json(Props);
+from_json([{Key, _} | _] = Props) when Key =/= struct -> lists:foldr(fun props_skip/2, [], Props);
+from_json([_|_] = NonEmptyList) -> [from_json(X) || X <- NonEmptyList];
+from_json(Any) -> Any.
+
+props_skip({<<BinaryKey/binary>>, Value}, Acc) ->
+    try Key = list_to_existing_atom(binary_to_list(BinaryKey)),
+        props_skip({Key, Value}, Acc)
+    catch _:_ -> Acc end;
+props_skip({Key, Value}, Acc) -> [{Key, from_json(Value)} | Acc].
+
+to_json(Data) ->
+    case wf_utils:is_string(Data) of
+        true  -> wf:to_binary(Data);
+        false -> json_match(Data)
+    end.
+
+json_match([{_, _} | _] = Props) -> [{wf:to_binary(Key), to_json(Value)} || {Key, Value} <- Props];
+json_match([_ | _] = NonEmptyList) -> [to_json(X) || X <- NonEmptyList];
+json_match(Any) -> Any.

+ 6 - 0
src/rest_app.erl

@@ -0,0 +1,6 @@
+-module(rest_app).
+-behaviour(application).
+-export([start/2, stop/1]).
+
+start(_StartType, _StartArgs) -> rest_sup:start_link().
+stop(_State) -> ok.

+ 101 - 0
src/rest_cowboy.erl

@@ -0,0 +1,101 @@
+-module(rest_cowboy).
+-author('Dmitry Bushmelev').
+-record(st, {resource_module = undefined :: atom(), resource_id = undefined :: binary()}).
+-export([init/3, rest_init/2, resource_exists/2, allowed_methods/2, content_types_provided/2,
+         to_html/2, to_json/2, content_types_accepted/2, delete_resource/2,
+         handle_urlencoded_data/2, handle_json_data/2]).
+
+init(_, _, _) -> {upgrade, protocol, cowboy_rest}.
+
+rest_init(Req, _Opts) ->
+    {Resource, Req1} = cowboy_req:binding(resource, Req),
+    Module = case rest_module(Resource) of {ok, M} -> M; _ -> undefined end,
+    {Id, Req2} = cowboy_req:binding(id, Req1),
+    Req3 = wf:header(<<"Access-Control-Allow-Origin">>, <<"*">>, Req2),
+    {ok, Req3, #st{resource_module = Module, resource_id = Id}}.
+
+resource_exists(Req, #st{resource_module = undefined} = State)       -> {false, Req, State};
+resource_exists(Req, #st{resource_id     = undefined} = State)       -> {true, Req, State};
+resource_exists(Req, #st{resource_module = M, resource_id = Id} = S) -> {M:exists(Id), Req, S}.
+
+allowed_methods(Req, #st{resource_id = undefined} = State) -> {[<<"GET">>, <<"POST">>], Req, State};
+allowed_methods(Req, State)                                -> {[<<"GET">>, <<"PUT">>, <<"DELETE">>], Req, State}.
+
+content_types_provided(Req, #st{resource_module = M} = State) ->
+    {case erlang:function_exported(M, to_html, 1) of
+         true  -> [{<<"text/html">>, to_html}, {<<"application/json">>, to_json}];
+         false -> [{<<"application/json">>, to_json}] end,
+     Req, State}.
+
+to_html(Req, #st{resource_module = M, resource_id = Id} = State) ->
+    Body = case Id of
+               undefined -> [M:to_html(Resource) || Resource <- M:get()];
+               _ -> M:to_html(M:get(Id)) end,
+    Html = case erlang:function_exported(M, html_layout, 2) of
+               true  -> M:html_layout(Req, Body);
+               false -> default_html_layout(Body) end,
+    {Html, Req, State}.
+
+default_html_layout(Body) -> [<<"<html><body>">>, Body, <<"</body></html>">>].
+
+to_json(Req, #st{resource_module = M, resource_id = Id} = State) ->
+    Struct = case Id of
+                 undefined -> {struct, [{M, [{struct, M:to_json(Resource)} || Resource <- M:get()]}]};
+                 _         -> {struct, M:to_json(M:get(Id))} end,
+    {iolist_to_binary(n2o_json:encode(Struct)), Req, State}.
+
+content_types_accepted(Req, State) -> {[{<<"application/x-www-form-urlencoded">>, handle_urlencoded_data},
+                                        {<<"application/json">>, handle_json_data}], Req, State}.
+
+handle_urlencoded_data(Req, #st{resource_module = M, resource_id = Id} = State) ->
+    {ok, Data, Req2} = cowboy_req:body_qs(Req),
+    {handle_data(M, Id, Data), Req2, State}.
+
+handle_json_data(Req, #st{resource_module = M, resource_id = Id} = State) ->
+    {ok, Binary, Req2} = cowboy_req:body(Req),
+    Data = case n2o_json:decode(Binary) of {struct, Struct} -> Struct; _ -> [] end,
+    {handle_data(M, Id, Data), Req2, State}.
+
+handle_data(Mod, Id, Data) ->
+    Valid = case erlang:function_exported(Mod, validate, 2) of
+                true  -> Mod:validate(Id, Data);
+                false -> default_validate(Mod, Id, Data) end,
+    case {Valid, Id} of
+        {false, _}         -> false;
+        {true,  undefined} -> Mod:post(Data);
+        {true,  _}         -> case erlang:function_exported(Mod, put, 2) of
+                                  true  -> Mod:put(Id, Data);
+                                  false -> default_put(Mod, Id, Data) end
+    end.
+
+default_put(Mod, Id, Data) ->
+    NewRes = Mod:from_json(Data, Mod:get(Id)),
+    NewId = proplists:get_value(id, Mod:to_json(NewRes)),
+    case Id =/= NewId of
+        true  -> Mod:delete(Id);
+        false -> true end,
+    Mod:post(NewRes).
+
+default_validate(Mod, Id, Data) ->
+    Allowed = case erlang:function_exported(Mod, keys_allowed, 1) of
+                  true  -> Mod:keys_allowed(proplists:get_keys(Data));
+                  false -> true end,
+    validate_match(Mod, Id, Allowed, proplists:get_value(<<"id">>, Data)).
+
+validate_match(_Mod, undefined, true, undefined) -> false;
+validate_match(_Mod, undefined, true, <<"">>)    -> false;
+validate_match( Mod, undefined, true, NewId)     -> not Mod:exists(NewId);
+validate_match(_Mod,       _Id, true, undefined) -> true;
+validate_match(_Mod,        Id, true, Id)        -> true;
+validate_match( Mod,       _Id, true, NewId)     -> not Mod:exists(NewId);
+validate_match(   _,         _,    _, _)         -> false.
+
+delete_resource(Req,  #st{resource_module = M, resource_id = Id} = State) -> {M:delete(Id), Req, State}.
+
+rest_module(Module) when is_binary(Module) -> rest_module(binary_to_list(Module));
+rest_module(Module) ->
+    try M = list_to_existing_atom(Module),
+        Info = proplists:get_value(attributes, M:module_info()),
+        true = lists:member(rest, proplists:get_value(behaviour, Info)),
+        {ok, M}
+    catch error:Error -> {error, Error} end.

+ 11 - 0
src/rest_sup.erl

@@ -0,0 +1,11 @@
+-module(rest_sup).
+-behaviour(supervisor).
+-export([start_link/0, init/1]).
+-compile(export_all).
+
+start_link() -> supervisor:start_link({local, ?MODULE}, ?MODULE, []).
+
+init([]) ->
+
+    {ok, {{one_for_one, 5, 10}, []}}.
+