./ct_report/coverage/mongoose_api_common.COVER.html

1 %%%-------------------------------------------------------------------
2 %%% @author ludwikbukowski
3 %%% @copyright (C) 2016, Erlang Solutions Ltd.
4 %%% Created : 20. Jul 2016 10:16
5 %%%-------------------------------------------------------------------
6
7 %% @doc MongooseIM REST API backend
8 %%
9 %% This module handles the client HTTP REST requests, then respectively convert
10 %% them to Commands from mongoose_commands and execute with `admin` privileges.
11 %% It supports responses with appropriate HTTP Status codes returned to the
12 %% client.
13 %% This module implements behaviour introduced in ejabberd_cowboy which is
14 %% %% built on top of the cowboy library.
15 %% The method supported: GET, POST, PUT, DELETE. Only JSON format.
16 %% The library "jiffy" used to serialize and deserialized JSON data.
17 %%
18 %% REQUESTS
19 %%
20 %% The module is based on mongoose_commands registry.
21 %% The root http path for a command is build based on the "category" field.
22 %% %% It's always used as path a prefix.
23 %% The commands are translated to HTTP API in the following manner:
24 %%
25 %% command of action "read" will be called by GET request
26 %% command of action "create" will be called by POST request
27 %% command of action "update" will be called by PUT request
28 %% command of action "delete" will be called by DELETE request
29 %%
30 %% The args of the command will be filled with the values provided in path
31 %% %% bindings or body parameters, depending of the method type:
32 %% - for command of action "read" or "delete" all the args are pulled from the
33 %% path bindings. The path should be constructed of pairs "/arg_name/arg_value"
34 %% so that it could match the {arg_name, type} %% pattern in the command
35 %% registry. E.g having the record of category "users" and args:
36 %% [{username, binary}, {domain, binary}] we will have to make following GET
37 %% request %% path: http://domain:port/api/users/username/Joe/domain/localhost
38 %% and the command will be called with arguments "Joe" and "localhost"
39 %%
40 %% - for command of action "create" or "update" args are pulled from the body
41 %% JSON, except those that are on the "identifiers" list of the command. Those
42 %% go to the path bindings.
43 %% E.g having the record of category "animals", action "update" and args:
44 %% [{species, binary}, {name, binary}, {age, integer}]
45 %% and identifiers:
46 %% [species, name]
47 %% we can set the age for our elephant Ed in the PUT request:
48 %% path: http://domain:port/api/species/elephant/name/Ed
49 %% body: {"age" : "10"}
50 %% and then the command will be called with arguments ["elephant", "Ed" and 10].
51 %%
52 %% RESPONSES
53 %%
54 %% The API supports some of the http status code like 200, 201, 400, 404 etc
55 %% depending on the return value of the command execution and arguments checks.
56 %% Additionally, when the command's action is "create" and it returns a value,
57 %% it is concatenated to the path and return to the client in header "location"
58 %% with response code 201 so that it could represent now a new created resource.
59 %% If error occured while executing the command, the appropriate reason is
60 %% returned in response body.
61
62 -module(mongoose_api_common).
63 -author("ludwikbukowski").
64 -include("mongoose_api.hrl").
65 -include("mongoose.hrl").
66
67 %% API
68 -export([create_admin_url_path/1,
69 create_user_url_path/1,
70 action_to_method/1,
71 method_to_action/1,
72 parse_request_body/1,
73 get_allowed_methods/1,
74 process_request/4,
75 reload_dispatches/1,
76 get_auth_details/1,
77 is_known_auth_method/1,
78 error_response/4,
79 make_unauthorized_response/2,
80 check_password/2]).
81
82 -ignore_xref([reload_dispatches/1]).
83
84 %% @doc Reload all ejabberd_cowboy listeners.
85 %% When a command is registered or unregistered, the routing paths that
86 %% cowboy stores as a "dispatch" must be refreshed.
87 %% Read more http://ninenines.eu/docs/en/cowboy/1.0/guide/routing/
88 reload_dispatches(drop) ->
89
:-(
drop;
90 reload_dispatches(_Command) ->
91 8107 Listeners = supervisor:which_children(mongoose_listener_sup),
92 8107 CowboyListeners = [Child || {_Id, Child, _Type, [ejabberd_cowboy]} <- Listeners],
93 8107 [ejabberd_cowboy:reload_dispatch(Child) || Child <- CowboyListeners],
94 8107 drop.
95
96
97 -spec create_admin_url_path(mongoose_commands:t()) -> ejabberd_cowboy:path().
98 create_admin_url_path(Command) ->
99 4140 iolist_to_binary(create_admin_url_path_iodata(Command)).
100
101 create_admin_url_path_iodata(Command) ->
102 4140 ["/", mongoose_commands:category(Command),
103 maybe_add_bindings(Command, admin), maybe_add_subcategory(Command)].
104
105 -spec create_user_url_path(mongoose_commands:t()) -> ejabberd_cowboy:path().
106 create_user_url_path(Command) ->
107
:-(
iolist_to_binary(create_user_url_path_iodata(Command)).
108
109 create_user_url_path_iodata(Command) ->
110
:-(
["/", mongoose_commands:category(Command), maybe_add_bindings(Command, user)].
111
112 -spec process_request(Method :: method(),
113 Command :: mongoose_commands:t(),
114 Req :: cowboy_req:req() | {list(), cowboy_req:req()},
115 State :: http_api_state()) ->
116 {any(), cowboy_req:req(), http_api_state()}.
117 process_request(Method, Command, Req, #http_api_state{bindings = Binds, entity = Entity} = State)
118 when ((Method == <<"POST">>) or (Method == <<"PUT">>)) ->
119 60 {Params, Req2} = Req,
120 60 QVals = cowboy_req:parse_qs(Req2),
121 60 QV = [{binary_to_existing_atom(K, utf8), V} || {K, V} <- QVals],
122 60 Params2 = Binds ++ Params ++ QV ++ maybe_add_caller(Entity),
123 60 handle_request(Method, Command, Params2, Req2, State);
124 process_request(Method, Command, Req, #http_api_state{bindings = Binds, entity = Entity}=State)
125 when ((Method == <<"GET">>) or (Method == <<"DELETE">>)) ->
126 50 QVals = cowboy_req:parse_qs(Req),
127 50 QV = [{binary_to_existing_atom(K, utf8), V} || {K, V} <- QVals],
128 50 BindsAndVars = Binds ++ QV ++ maybe_add_caller(Entity),
129 50 handle_request(Method, Command, BindsAndVars, Req, State).
130
131 -spec handle_request(Method :: method(),
132 Command :: mongoose_commands:t(),
133 Args :: args_applied(),
134 Req :: cowboy_req:req(),
135 State :: http_api_state()) ->
136 {any(), cowboy_req:req(), http_api_state()}.
137 handle_request(Method, Command, Args, Req, #http_api_state{entity = Entity} = State) ->
138 110 case check_and_extract_args(mongoose_commands:args(Command),
139 mongoose_commands:optargs(Command), Args) of
140 {error, Type, Reason} ->
141 1 handle_result(Method, {error, Type, Reason}, Req, State);
142 ConvertedArgs ->
143 109 handle_result(Method,
144 execute_command(ConvertedArgs, Command, Entity),
145 Req, State)
146 end.
147
148 -type correct_result() :: mongoose_commands:success().
149 -type error_result() :: mongoose_commands:failure().
150
151 -spec handle_result(Method, Result, Req, State) -> Return when
152 Method :: method() | no_call,
153 Result :: correct_result() | error_result(),
154 Req :: cowboy_req:req(),
155 State :: http_api_state(),
156 Return :: {any(), cowboy_req:req(), http_api_state()}.
157 handle_result(<<"DELETE">>, ok, Req, State) ->
158 6 Req2 = cowboy_req:reply(204, Req),
159 6 {stop, Req2, State};
160 handle_result(<<"DELETE">>, {ok, Res}, Req, State) ->
161 2 Req2 = cowboy_req:set_resp_body(jiffy:encode(Res), Req),
162 2 Req3 = cowboy_req:reply(200, Req2),
163 2 {jiffy:encode(Res), Req3, State};
164 handle_result(Verb, ok, Req, State) ->
165 24 handle_result(Verb, {ok, nocontent}, Req, State);
166 handle_result(<<"GET">>, {ok, Result}, Req, State) ->
167 39 {jiffy:encode(Result), Req, State};
168 handle_result(<<"POST">>, {ok, nocontent}, Req, State) ->
169 16 Req2 = cowboy_req:reply(204, Req),
170 16 {stop, Req2, State};
171 handle_result(<<"POST">>, {ok, Res}, Req, State) ->
172 7 Path = iolist_to_binary(cowboy_req:uri(Req)),
173 7 Req2 = cowboy_req:set_resp_body(Res, Req),
174 7 Req3 = maybe_add_location_header(Res, binary_to_list(Path), Req2),
175 7 {stop, Req3, State};
176 handle_result(<<"PUT">>, {ok, nocontent}, Req, State) ->
177 8 Req2 = cowboy_req:reply(204, Req),
178 8 {stop, Req2, State};
179 handle_result(<<"PUT">>, {ok, Res}, Req, State) ->
180 2 Req2 = cowboy_req:set_resp_body(Res, Req),
181 2 Req3 = cowboy_req:reply(201, Req2),
182 2 {stop, Req3, State};
183 handle_result(_, {error, Error, Reason}, Req, State) ->
184 30 error_response(Error, Reason, Req, State);
185 handle_result(no_call, _, Req, State) ->
186
:-(
error_response(not_implemented, <<>>, Req, State).
187
188
189 -spec parse_request_body(any()) -> {args_applied(), cowboy_req:req()} | {error, any()}.
190 parse_request_body(Req) ->
191 60 {ok, Body, Req2} = cowboy_req:read_body(Req),
192 60 {Data} = jiffy:decode(Body),
193 60 try
194 60 Params = create_params_proplist(Data),
195 60 {Params, Req2}
196 catch
197 Class:Reason:StackTrace ->
198
:-(
?LOG_ERROR(#{what => parse_request_body_failed, class => Class,
199
:-(
reason => Reason, stacktrace => StackTrace}),
200
:-(
{error, Reason}
201 end.
202
203 %% @doc Checks if the arguments are correct. Return the arguments that can be applied to the
204 %% execution of command.
205 -spec check_and_extract_args(arg_spec_list(), optarg_spec_list(), args_applied()) ->
206 map() | {error, atom(), any()}.
207 check_and_extract_args(ReqArgs, OptArgs, RequestArgList) ->
208 110 try
209 110 AllArgs = ReqArgs ++ [{N, T} || {N, T, _} <- OptArgs],
210 110 AllArgVals = [{N, T, proplists:get_value(N, RequestArgList)} || {N, T} <- AllArgs],
211 110 ConvArgs = [{N, convert_arg(T, V)} || {N, T, V} <- AllArgVals, V =/= undefined],
212 109 maps:from_list(ConvArgs)
213 catch
214 Class:Reason:StackTrace ->
215 1 ?LOG_ERROR(#{what => check_and_extract_args_failed, class => Class,
216
:-(
reason => Reason, stacktrace => StackTrace}),
217 1 {error, bad_request, Reason}
218 end.
219
220 -spec execute_command(mongoose_commands:args(),
221 mongoose_commands:t(),
222 mongoose_commands:caller()) ->
223 correct_result() | error_result().
224 execute_command(ArgMap, Command, Entity) ->
225 109 mongoose_commands:execute(Entity, mongoose_commands:name(Command), ArgMap).
226
227 -spec maybe_add_caller(admin | binary()) -> list() | list({caller, binary()}).
228 maybe_add_caller(admin) ->
229 110 [];
230 maybe_add_caller(JID) ->
231
:-(
[{caller, JID}].
232
233 -spec maybe_add_location_header(binary() | list(), list(), any())
234 -> cowboy_req:req().
235 maybe_add_location_header(Result, ResourcePath, Req) when is_binary(Result) ->
236 7 add_location_header(binary_to_list(Result), ResourcePath, Req);
237 maybe_add_location_header(Result, ResourcePath, Req) when is_list(Result) ->
238
:-(
add_location_header(Result, ResourcePath, Req);
239 maybe_add_location_header(_, _Path, Req) ->
240
:-(
cowboy_req:reply(204, #{}, Req).
241
242 add_location_header(Result, ResourcePath, Req) ->
243 7 Path = [ResourcePath, "/", Result],
244 7 Headers = #{<<"location">> => Path},
245 7 cowboy_req:reply(201, Headers, Req).
246
247 -spec convert_arg(atom(), any()) -> boolean() | integer() | float() | binary() | string() | {error, bad_type}.
248 convert_arg(binary, Binary) when is_binary(Binary) ->
249 262 Binary;
250 convert_arg(boolean, Value) when is_boolean(Value) ->
251 1 Value;
252 convert_arg(integer, Binary) when is_binary(Binary) ->
253 9 binary_to_integer(Binary);
254 convert_arg(integer, Integer) when is_integer(Integer) ->
255
:-(
Integer;
256 convert_arg(float, Binary) when is_binary(Binary) ->
257
:-(
binary_to_float(Binary);
258 convert_arg(float, Float) when is_float(Float) ->
259
:-(
Float;
260 convert_arg([Type], List) when is_list(List) ->
261
:-(
[ convert_arg(Type, Item) || Item <- List ];
262 convert_arg(_, _Binary) ->
263 1 throw({error, bad_type}).
264
265 -spec create_params_proplist(list({binary(), binary()})) -> args_applied().
266 create_params_proplist(ArgList) ->
267 60 lists:sort([{to_atom(Arg), Value} || {Arg, Value} <- ArgList]).
268
269 %% @doc Returns list of allowed methods.
270 -spec get_allowed_methods(admin | user) -> list(method()).
271 get_allowed_methods(Entity) ->
272 112 Commands = mongoose_commands:list(Entity),
273 112 [action_to_method(mongoose_commands:action(Command)) || Command <- Commands].
274
275 -spec maybe_add_bindings(mongoose_commands:t(), admin|user) -> iolist().
276 maybe_add_bindings(Command, Entity) ->
277 4140 Action = mongoose_commands:action(Command),
278 4140 Args = mongoose_commands:args(Command),
279 4140 BindAndBody = both_bind_and_body(Action),
280 4140 case BindAndBody of
281 true ->
282 2376 Ids = mongoose_commands:identifiers(Command),
283 2376 Bindings = [El || {Key, _Value} = El <- Args, true =:= proplists:is_defined(Key, Ids)],
284 2376 add_bindings(Bindings, Entity);
285 false ->
286 1764 add_bindings(Args, Entity)
287 end.
288
289 maybe_add_subcategory(Command) ->
290 4140 SubCategory = mongoose_commands:subcategory(Command),
291 4140 case SubCategory of
292 undefined ->
293 2800 [];
294 _ ->
295 1340 ["/", SubCategory]
296 end.
297
298 -spec both_bind_and_body(mongoose_commands:action()) -> boolean().
299 both_bind_and_body(update) ->
300 768 true;
301 both_bind_and_body(create) ->
302 1608 true;
303 both_bind_and_body(read) ->
304 840 false;
305 both_bind_and_body(delete) ->
306 924 false.
307
308 add_bindings(Args, Entity) ->
309 4140 [add_bind(A, Entity) || A <- Args].
310
311 %% skip "caller" arg for frontend command
312 add_bind({caller, _}, user) ->
313
:-(
"";
314 add_bind({ArgName, _}, _Entity) ->
315 5938 lists:flatten(["/:", atom_to_list(ArgName)]);
316 add_bind(Other, _) ->
317
:-(
throw({error, bad_arg_spec, Other}).
318
319 -spec to_atom(binary() | atom()) -> atom().
320 to_atom(Bin) when is_binary(Bin) ->
321 122 erlang:binary_to_existing_atom(Bin, utf8);
322 to_atom(Atom) when is_atom(Atom) ->
323
:-(
Atom.
324
325 %%--------------------------------------------------------------------
326 %% HTTP utils
327 %%--------------------------------------------------------------------
328 %%-spec error_response(mongoose_commands:errortype(), any(), http_api_state()) ->
329 %% {stop, any(), http_api_state()}.
330 %%error_response(ErrorType, Req, State) ->
331 %% error_response(ErrorType, <<>>, Req, State).
332
333 -spec error_response(mongoose_commands:errortype(), mongoose_commands:errorreason(), cowboy_req:req(), http_api_state()) ->
334 {stop, cowboy_req:req(), http_api_state()}.
335 error_response(ErrorType, Reason, Req, State) ->
336 31 BinReason = case Reason of
337 4 B when is_binary(B) -> B;
338 26 L when is_list(L) -> list_to_binary(L);
339 1 Other -> list_to_binary(io_lib:format("~p", [Other]))
340 end,
341 31 ?LOG_ERROR(#{what => rest_common_error,
342
:-(
error_type => ErrorType, reason => Reason, req => Req}),
343 31 Req1 = cowboy_req:reply(error_code(ErrorType), #{}, BinReason, Req),
344 31 {stop, Req1, State}.
345
346 %% HTTP status codes
347 2 error_code(denied) -> 403;
348
:-(
error_code(not_implemented) -> 501;
349 9 error_code(bad_request) -> 400;
350 12 error_code(type_error) -> 400;
351 7 error_code(not_found) -> 404;
352 1 error_code(internal) -> 500;
353 error_code(Other) ->
354
:-(
?WARNING_MSG("Unknown error identifier \"~p\". See mongoose_commands:errortype() for allowed values.", [Other]),
355
:-(
500.
356
357 799 action_to_method(read) -> <<"GET">>;
358 702 action_to_method(update) -> <<"PUT">>;
359 842 action_to_method(delete) -> <<"DELETE">>;
360 1371 action_to_method(create) -> <<"POST">>;
361
:-(
action_to_method(_) -> undefined.
362
363 40 method_to_action(<<"GET">>) -> read;
364 41 method_to_action(<<"POST">>) -> create;
365 19 method_to_action(<<"PUT">>) -> update;
366 11 method_to_action(<<"DELETE">>) -> delete.
367
368 %%--------------------------------------------------------------------
369 %% Authorization
370 %%--------------------------------------------------------------------
371
372 -spec get_auth_details(cowboy_req:req()) ->
373 {basic, User :: binary(), Password :: binary()} | undefined.
374 get_auth_details(Req) ->
375 251 case cowboy_req:parse_header(<<"authorization">>, Req) of
376 {basic, _User, _Password} = Details ->
377 140 Details;
378 _ ->
379 111 undefined
380 end.
381
382 -spec is_known_auth_method(atom()) -> boolean().
383 138 is_known_auth_method(basic) -> true;
384
:-(
is_known_auth_method(_) -> false.
385
386 make_unauthorized_response(Req, State) ->
387 3 {{false, <<"Basic realm=\"mongooseim\"">>}, Req, State}.
388
389 -spec check_password(jid:jid() | error, binary()) -> {true, mongoose_credentials:t()} | false.
390 check_password(error, _) ->
391
:-(
false;
392 check_password(JID, Password) ->
393 290 {LUser, LServer} = jid:to_lus(JID),
394 290 case mongoose_domain_api:get_domain_host_type(LServer) of
395 {ok, HostType} ->
396 290 Creds0 = mongoose_credentials:new(LServer, HostType, #{}),
397 290 Creds1 = mongoose_credentials:set(Creds0, username, LUser),
398 290 Creds2 = mongoose_credentials:set(Creds1, password, Password),
399 290 case ejabberd_auth:authorize(Creds2) of
400 290 {ok, Creds} -> {true, Creds};
401
:-(
_ -> false
402 end;
403
:-(
{error, not_found} -> false
404 end.
Line Hits Source