%%% @doc Base GET|POST /[entities] implementation
-module(sr_entities_handler).
-export([ init/3
, rest_init/2
, allowed_methods/2
, resource_exists/2
, content_types_accepted/2
, content_types_provided/2
, handle_get/2
, handle_post/2
]).
-export([ announce_req/2
, handle_post/3
]).
-type options() :: #{ path => string()
, model => atom()
, verbose => boolean()
}.
-type state() :: sr_state:state().
-export_type([state/0, options/0]).
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%% Cowboy Callbacks
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%% @doc Upgrades to cowboy_rest.
%% Basically, just returns {upgrade, protocol, cowboy_rest}
%% @see cowboy_rest:init/3
-spec init({atom(), atom()}, cowboy_req:req(), options()) ->
{upgrade, protocol, cowboy_rest}.
init(_Transport, _Req, _Opts) ->
{upgrade, protocol, cowboy_rest}.
%% @doc Announces the Req and moves on.
%% If verbose := true in Opts for this handler
%% prints out a line indicating that endpoint that was hit.
%% @see cowboy_rest:rest_init/2
-spec rest_init(cowboy_req:req(), options()) ->
{ok, cowboy_req:req(), state()}.
rest_init(Req, Opts) ->
Req1 = announce_req(Req, Opts),
#{model := Model} = Opts,
Module = sumo_config:get_prop_value(Model, module),
State = sr_state:new(Opts, Module),
{ok, Req1, State}.
%% @doc Retrieves the list of allowed methods from Trails metadata.
%% Parses the metadata associated with this path and returns the
%% corresponding list of endpoints.
%% @see cowboy_rest:allowed_methods/2
-spec allowed_methods(cowboy_req:req(), state()) ->
{[binary()], cowboy_req:req(), state()}.
allowed_methods(Req, State) ->
#{path := Path} = sr_state:opts(State),
Trail = trails:retrieve(Path),
Metadata = trails:metadata(Trail),
Methods = [atom_to_method(Method) || Method <- maps:keys(Metadata)],
{Methods, Req, State}.
%% @doc Returns false for POST, true otherwise.
%% @see cowboy_rest:resource_exists/2
-spec resource_exists(cowboy_req:req(), state()) ->
{boolean(), cowboy_req:req(), state()}.
resource_exists(Req, State) ->
{Method, Req1} = cowboy_req:method(Req),
{Method =/= <<"POST">>, Req1, State}.
%% @doc Always returns "application/json *" with handle_post.
%% @see cowboy_rest:content_types_accepted/2
%% @todo Use swagger's 'consumes' to auto-generate this if possible
%% Issue
-spec content_types_accepted(cowboy_req:req(), state()) ->
{[{{binary(), binary(), '*'}, atom()}], cowboy_req:req(), state()}.
content_types_accepted(Req, State) ->
#{path := Path} = sr_state:opts(State),
{Method, Req2} = cowboy_req:method(Req),
try
Trail = trails:retrieve(Path),
Metadata = trails:metadata(Trail),
AtomMethod = method_to_atom(Method),
#{AtomMethod := #{consumes := Consumes}} = Metadata,
Handler = compose_handler_name(AtomMethod),
RetList = [{iolist_to_binary(X), Handler} || X <- Consumes],
{RetList, Req2, State}
catch
_:_ -> {[{{<<"application">>, <<"json">>, '*'}, handle_post}], Req, State}
end.
%% @doc Always returns "application/json" with handle_get.
%% @see cowboy_rest:content_types_provided/2
%% @todo Use swagger's 'produces' to auto-generate this if possible
%% Issue
-spec content_types_provided(cowboy_req:req(), state()) ->
{[{binary(), atom()}], cowboy_req:req(), state()}.
content_types_provided(Req, State) ->
#{path := Path} = sr_state:opts(State),
{Method, Req2} = cowboy_req:method(Req),
try
Trail = trails:retrieve(Path),
Metadata = trails:metadata(Trail),
AtomMethod = method_to_atom(Method),
#{AtomMethod := #{produces := Produces}} = Metadata,
Handler = compose_handler_name(AtomMethod),
RetList = [{iolist_to_binary(X), Handler} || X <- Produces],
{RetList, Req2, State}
catch
_:_ -> {[{<<"application/json">>, handle_get}], Req, State}
end.
%% @doc Returns the list of all entities.
%% Fetches the entities from SumoDB using the
%% model provided in the options.
%% @todo Use query-string as filters.
%% Issue
-spec handle_get(cowboy_req:req(), state()) ->
{iodata(), cowboy_req:req(), state()}.
handle_get(Req, State) ->
#{model := Model} = sr_state:opts(State),
Module = sr_state:module(State),
{Qs, Req1} = cowboy_req:qs_vals(Req),
Conditions = [ {binary_to_atom(Name, unicode),
Value} || {Name, Value} <- Qs ],
Schema = sumo_internal:get_schema(Model),
Fields = [ sumo_internal:field_name(Field) ||
Field <- sumo_internal:schema_fields(Schema) ],
CompareFun = fun({Name, _}) ->
true =:= lists:member(Name, Fields)
end,
ValidConditions = lists:filter(CompareFun, Conditions),
Entities = case ValidConditions of
[] -> sumo:find_all(Model);
_ -> sumo:find_by(Model, Conditions)
end,
Reply = [Module:to_json(Entity) || Entity <- Entities],
JSON = sr_json:encode(Reply),
{JSON, Req1, State}.
%% @doc Creates a new entity.
%% To parse the body, it uses from_ctx or
%% from_json/2 from the
%% model provided in the options.
-spec handle_post(cowboy_req:req(), state()) ->
{{true, binary()} | false | halt, cowboy_req:req(), state()}.
handle_post(Req, State) ->
Module = sr_state:module(State),
try
{SrRequest, Req1} = sr_request:from_cowboy(Req),
Result = case erlang:function_exported(Module, from_ctx, 1) of
false ->
Json = sr_request:body(SrRequest),
Module:from_json(Json);
true ->
Context = #{req => SrRequest, state => State},
Module:from_ctx(Context)
end,
case Result of
{error, Reason} ->
Req2 = cowboy_req:set_resp_body(sr_json:error(Reason), Req1),
{false, Req2, State};
{ok, Entity} ->
handle_post(Entity, Req1, State)
end
catch
_:conflict ->
{ok, Req3} =
cowboy_req:reply(422, [], sr_json:error(<<"Duplicated entity">>), Req),
{halt, Req3, State};
_:badjson ->
Req3 =
cowboy_req:set_resp_body(
sr_json:error(<<"Malformed JSON request">>), Req),
{false, Req3, State}
end.
%% @doc Persists a new entity.
%% The body must have been parsed beforehand.
-spec handle_post(sumo:user_doc(), cowboy_req:req(), state()) ->
{{true, binary()}, cowboy_req:req(), state()}.
handle_post(Entity, Req1, State) ->
#{model := Model, path := Path} = sr_state:opts(State),
Module = sr_state:module(State),
case erlang:function_exported(Module, duplication_conditions, 1) of
false -> proceed;
true ->
Conditions = Module:duplication_conditions(Entity),
case sumo:find_one(Model, Conditions) of
notfound -> proceed;
Duplicate ->
error_logger:warning_msg( "Duplicated ~p with conditions ~p: ~p"
, [Model, Conditions, Duplicate]),
throw(conflict)
end
end,
PersistedEntity = sumo:persist(Model, Entity),
ResBody = sr_json:encode(Module:to_json(PersistedEntity)),
Req2 = cowboy_req:set_resp_body(ResBody, Req1),
Location = Module:location(PersistedEntity, Path),
{{true, Location}, Req2, State}.
%% @doc Announces the Req.
%% If verbose := true in Opts for this handler
%% prints out a line indicating that endpoint that was hit.
%% @see cowboy_rest:rest_init/2
-spec announce_req(cowboy_req:req(), options()) -> cowboy_req:req().
announce_req(Req, #{verbose := true}) ->
{Method, Req1} = cowboy_req:method(Req),
{Path, Req2} = cowboy_req:path(Req1),
_ = error_logger:info_msg("~s ~s", [Method, Path]),
Req2;
announce_req(Req, _Opts) -> Req.
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%% Auxiliary Functions
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-spec atom_to_method(get|patch|put|post|delete) -> binary().
atom_to_method(get) -> <<"GET">>;
atom_to_method(patch) -> <<"PATCH">>;
atom_to_method(put) -> <<"PUT">>;
atom_to_method(post) -> <<"POST">>;
atom_to_method(delete) -> <<"DELETE">>.
-spec method_to_atom(binary()) -> atom().
method_to_atom(<<"GET">>) -> get;
method_to_atom(<<"PATCH">>) -> patch;
method_to_atom(<<"PUT">>) -> put;
method_to_atom(<<"POST">>) -> post;
method_to_atom(<<"DELETE">>) -> delete.
-spec compose_handler_name(get|patch|put|post) -> atom().
compose_handler_name(get) -> handle_get;
compose_handler_name(put) -> handle_put;
compose_handler_name(patch) -> handle_patch;
compose_handler_name(post) -> handle_post.