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. |