Current section
Files
Jump to
Current section
Files
src/cloudi_service_http_rest.erl
%-*-Mode:erlang;coding:utf-8;tab-width:4;c-basic-offset:4;indent-tabs-mode:()-*-
% ex: set ft=erlang fenc=utf-8 sts=4 ts=4 sw=4 et nomod:
%%%
%%%------------------------------------------------------------------------
%%% @doc
%%% ==CloudI HTTP REST Integration==
%%% Provide an easy way of connecting CloudI service requests to
%%% Erlang function calls for a HTTP REST API, with service configuration
%%% arguments.
%%% @end
%%%
%%% MIT License
%%%
%%% Copyright (c) 2015-2019 Michael Truog <mjtruog at protonmail dot com>
%%%
%%% Permission is hereby granted, free of charge, to any person obtaining a
%%% copy of this software and associated documentation files (the "Software"),
%%% to deal in the Software without restriction, including without limitation
%%% the rights to use, copy, modify, merge, publish, distribute, sublicense,
%%% and/or sell copies of the Software, and to permit persons to whom the
%%% Software is furnished to do so, subject to the following conditions:
%%%
%%% The above copyright notice and this permission notice shall be included in
%%% all copies or substantial portions of the Software.
%%%
%%% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
%%% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
%%% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
%%% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
%%% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
%%% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
%%% DEALINGS IN THE SOFTWARE.
%%%
%%% @author Michael Truog <mjtruog at protonmail dot com>
%%% @copyright 2015-2019 Michael Truog
%%% @version 1.8.0 {@date} {@time}
%%%------------------------------------------------------------------------
-module(cloudi_service_http_rest).
-author('mjtruog at protonmail dot com').
-behaviour(cloudi_service).
%% cloudi_service callbacks
-export([cloudi_service_init/4,
cloudi_service_handle_request/11,
cloudi_service_handle_info/3,
cloudi_service_terminate/3]).
-include_lib("cloudi_core/include/cloudi_logger.hrl").
-define(DEFAULT_INITIALIZE, undefined). % see below:
% If necessary, provide an initialization function to be called
% for initializing REST handler state data. The initialize
% function can be specified as an anonymous function or a
% {module(), FunctionName :: atom()} tuple.
-define(DEFAULT_TERMINATE, undefined). % see below:
% If necessary, provide a terminate function to be called
% for terminating the REST handler's state data. The terminate
% function can be specified as an anonymous function or a
% {module(), FunctionName :: atom()} tuple.
-define(DEFAULT_HANDLERS, undefined). % see below:
% Provide a list of handler functions to be used with the
% service name prefix. Each handler function entry in the list
% takes the form:
% {Method :: atom() | string(), Path :: string(), handler()}
% (e.g., Method == 'GET', Path == "index.html")
% The handler function can be specified as an anonymous function or a
% {module(), FunctionName :: atom()} tuple.
-define(DEFAULT_INFO, undefined). % see below:
% If necessary, provide an info function to be called
% for Erlang messages received by the process. The info
% function can be specified as an anonymous function or a
% {module(), FunctionName :: atom()} tuple.
-define(DEFAULT_FORMATS, undefined). % see below:
% Provide a list of formats to handle that are added as file type
% suffixes on the URL path which also determine the content-type used
% for the request and response.
-define(DEFAULT_SET_CONTENT_DISPOSITION, false).
% Set the content-disposition header value for any content-type values
% that are tagged as an attachment.
-define(DEFAULT_USE_OPTIONS_METHOD, false).
% Provide default handling of the OPTIONS method based on the
% configured handlers.
-define(DEFAULT_USE_TRACE_METHOD, false).
% Provide default handling of the TRACE method based on the
% configured handlers.
-define(DEFAULT_DEBUG, false). % log output for debugging
-define(DEFAULT_DEBUG_LEVEL, trace).
-type method() :: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'HEAD' | 'OPTIONS' |
'PATCH' | 'TRACE' | 'CONNECT'.
-type initialize_f() :: fun((Args :: list(),
Timeout :: cloudi_service_api:
timeout_initialize_value_milliseconds(),
Dispatcher :: cloudi:dispatcher()) ->
{ok, State :: any()} |
{stop, Reason :: any()} |
{stop, Reason :: any(), State :: any()}).
-type handler_f_11() :: fun((Method :: method(),
Path :: cloudi:service_name_pattern(),
Parameters :: list(string()),
Format :: atom(),
RequestInfo :: any(),
Request :: any(),
Timeout :: cloudi:timeout_value_milliseconds(),
Priority :: cloudi:priority_value(),
TransId :: cloudi:trans_id(),
State :: any(),
Dispatcher :: cloudi:dispatcher()) ->
{reply, Response :: any(), NewState :: any()} |
{reply, ResponseInfo :: any(), Response :: any(), NewState :: any()} |
{forward, NextName :: cloudi:service_name(),
NextRequestInfo :: any(), NextRequest :: any(), NewState :: any()} |
{forward, NextName :: cloudi:service_name(),
NextRequestInfo :: any(), NextRequest :: any(),
NextTimeout :: cloudi:timeout_value_milliseconds(),
NextPriority :: cloudi:priority_value(), NewState :: any()} |
{noreply, NewState :: any()} |
{stop, Reason :: any(), NewState :: any()}).
-type info_f() :: fun((Request :: any(),
State :: any(),
Dispatcher :: cloudi:dispatcher()) ->
{noreply, NewState :: any()} |
{stop, Reason :: any(), NewState :: any()}).
-type terminate_f() :: fun((Reason :: any(),
Timeout :: cloudi_service_api:
timeout_terminate_value_milliseconds(),
State :: any()) ->
ok).
-export_type([method/0,
initialize_f/0,
handler_f_11/0,
info_f/0,
terminate_f/0]).
-record(api,
{
method :: method(),
path :: cloudi:service_name_pattern(),
parameters :: boolean(),
format :: atom(),
handler_f :: handler_f_11(),
arity :: 11
}).
-record(state,
{
prefix :: cloudi:service_name_pattern(),
lookup :: trie:trie(),
info_f :: info_f() | undefined,
terminate_f :: terminate_f() | undefined,
content_types :: #{atom() := {request | attachment, binary()}},
content_disposition :: boolean(),
debug_level :: off | trace | debug | info | warn | error | fatal,
api_state :: any()
}).
%%%------------------------------------------------------------------------
%%% External interface functions
%%%------------------------------------------------------------------------
%%%------------------------------------------------------------------------
%%% Callback functions from cloudi_service
%%%------------------------------------------------------------------------
cloudi_service_init(Args, Prefix, Timeout, Dispatcher) ->
Defaults = [
{initialize, ?DEFAULT_INITIALIZE},
{terminate, ?DEFAULT_TERMINATE},
{handlers, ?DEFAULT_HANDLERS},
{info, ?DEFAULT_INFO},
{formats, ?DEFAULT_FORMATS},
{set_content_disposition, ?DEFAULT_SET_CONTENT_DISPOSITION},
{use_options_method, ?DEFAULT_USE_OPTIONS_METHOD},
{use_trace_method, ?DEFAULT_USE_TRACE_METHOD},
{debug, ?DEFAULT_DEBUG},
{debug_level, ?DEFAULT_DEBUG_LEVEL}],
[Initialize, Terminate0, Handlers0, Info0, Formats0,
SetContentDisposition, UseOptionsMethod, UseTraceMethod,
Debug, DebugLevel | ArgsAPI] =
cloudi_proplists:take_values(Defaults, Args),
TerminateN = cloudi_args_type:function_optional(Terminate0, 3),
true = is_list(Handlers0),
lists:foreach(fun({Method, Path, _}) ->
MethodString = if
is_atom(Method) ->
erlang:atom_to_list(Method);
is_list(Method), is_integer(hd(Method)) ->
Method
end,
MethodString = cloudi_string:uppercase(MethodString),
true = is_list(Path) andalso is_integer(hd(Path))
end, Handlers0),
InfoN = cloudi_args_type:function_optional(Info0, 3),
true = is_list(Formats0),
true = is_boolean(SetContentDisposition),
true = is_boolean(UseOptionsMethod),
true = is_boolean(UseTraceMethod),
true = is_boolean(Debug),
true = ((DebugLevel =:= trace) orelse
(DebugLevel =:= debug) orelse
(DebugLevel =:= info) orelse
(DebugLevel =:= warn) orelse
(DebugLevel =:= error) orelse
(DebugLevel =:= fatal)),
DebugLogLevel = if
Debug =:= false ->
off;
Debug =:= true ->
DebugLevel
end,
ContentTypes = cloudi_response_info:lookup_content_type(),
FormatsN = lists:map(fun(Format0) ->
FormatN = if
is_list(Format0), is_integer(hd(Format0)) ->
Format0;
is_atom(Format0) ->
erlang:atom_to_list(Format0)
end,
true = trie:is_key("." ++ FormatN, ContentTypes),
FormatN
end, Formats0),
ContentTypeLookupN = lists:foldl(fun(FormatN, ContentTypeLookup0) ->
maps:put(erlang:list_to_atom(FormatN),
trie:fetch("." ++ FormatN, ContentTypes),
ContentTypeLookup0)
end, #{}, FormatsN),
MethodListsN = trie:to_list(lists:foldr(fun({Method, Path, _},
MethodLists0) ->
MethodString = if
is_atom(Method) ->
erlang:atom_to_list(Method);
is_list(Method) ->
Method
end,
trie:update(Path, fun(MethodList0) ->
lists:umerge(MethodList0, [MethodString])
end, [MethodString], MethodLists0)
end, trie:new(), Handlers0)),
Handlers1 = if
UseTraceMethod =:= true ->
lists:map(fun({Path, _}) ->
HandlerTrace = fun(_, _, _, _, _, _, _, _, _,
TraceHandlerState, _) ->
{reply,
[{<<"via">>, <<"1.1 CloudI">>}], <<>>, TraceHandlerState}
end,
{'TRACE', Path, HandlerTrace}
end, MethodListsN) ++ Handlers0;
UseTraceMethod =:= false ->
Handlers0
end,
HandlersN = if
UseOptionsMethod =:= true ->
lists:map(fun({Path, MethodList1}) ->
MethodListN = lists:umerge(MethodList1, ["OPTIONS"]),
Methods = erlang:list_to_binary(lists:join(", ", MethodListN)),
HandlerOptions = fun(_, _, _, _, _, _, _, _, _,
OptionsHandlerState, _) ->
% content-type is set automatically by HTTP process
{reply,
[{<<"allow">>, Methods}], <<>>, OptionsHandlerState}
end,
{'OPTIONS', Path, HandlerOptions}
end, MethodListsN) ++ Handlers1;
UseOptionsMethod =:= false ->
Handlers1
end,
LookupN = lists:foldl(fun({Method, Path, Handler0}, Lookup0) ->
{Handler1,
Arity} = cloudi_args_type:function_required_pick(Handler0, [11]),
HandlerMethod = if
is_atom(Method) ->
Method;
is_list(Method) ->
erlang:list_to_atom(Method)
end,
API = #api{method = HandlerMethod,
path = Path,
parameters = cloudi_service_name:pattern(Prefix ++ Path),
handler_f = Handler1,
arity = Arity},
subscribe_paths(Method, Path, FormatsN, API, Lookup0, Dispatcher)
end, trie:new(), HandlersN),
State = #state{prefix = Prefix,
lookup = LookupN,
info_f = InfoN,
terminate_f = TerminateN,
content_types = ContentTypeLookupN,
content_disposition = SetContentDisposition,
debug_level = DebugLogLevel},
ReturnAPI = case cloudi_args_type:function_optional(Initialize, 3) of
undefined ->
true = (ArgsAPI == []),
{ok, undefined};
InitializeFunction ->
InitializeFunction(ArgsAPI, Timeout, Dispatcher)
end,
case ReturnAPI of
{ok, StateAPI} ->
{ok, State#state{api_state = StateAPI}};
{stop, Reason} ->
{stop, Reason, State};
{stop, Reason, StateAPI} ->
{stop, Reason, State#state{api_state = StateAPI}}
end.
cloudi_service_handle_request(_RequestType, Name, Pattern, RequestInfo, Request,
Timeout, Priority, TransId, _Pid,
#state{prefix = Prefix,
lookup = Lookup,
content_types = ContentTypes,
content_disposition = ContentDisposition,
debug_level = DebugLevel,
api_state = StateAPI} = State,
Dispatcher) ->
Suffix = cloudi_service_name:suffix(Prefix, Pattern),
#api{method = Method,
path = Path,
parameters = Parameters,
format = Format,
handler_f = Handler,
arity = Arity} = trie:fetch(Suffix, Lookup),
ParametersL = if
Parameters =:= true ->
cloudi_service_name:parse(Name, Pattern);
Parameters =:= false ->
[]
end,
if
DebugLevel =:= off ->
ok;
RequestInfo /= <<>> ->
?LOG(DebugLevel,
"request ~p ~p",
[Name, {RequestInfo, Request}]);
true ->
?LOG(DebugLevel,
"request ~p ~p",
[Name, Request])
end,
true = is_list(ParametersL),
ReturnAPI = if
Arity == 11 ->
Handler(Method, Path, ParametersL, Format, RequestInfo, Request,
Timeout, Priority, TransId, StateAPI, Dispatcher)
end,
case ReturnAPI of
{reply, Response, NewStateAPI} ->
ResponseInfo = response_info_headers([], Name, Format,
ContentTypes,
ContentDisposition),
?LOG(DebugLevel,
"response ~p ~p",
[Name, {ResponseInfo, Response}]),
{reply, ResponseInfo, Response,
State#state{api_state = NewStateAPI}};
{reply, ResponseInfo, Response, NewStateAPI} ->
NewResponseInfo = response_info_headers(ResponseInfo, Name, Format,
ContentTypes,
ContentDisposition),
?LOG(DebugLevel,
"response ~p ~p",
[Name, {NewResponseInfo, Response}]),
{reply, NewResponseInfo, Response,
State#state{api_state = NewStateAPI}};
{forward, NextName, NextRequestInfo, NextRequest, NewStateAPI} ->
?LOG(DebugLevel,
"forward ~p to ~p ~p",
[Name, NextName, {NextRequestInfo, NextRequest}]),
{forward, NextName, NextRequestInfo, NextRequest,
State#state{api_state = NewStateAPI}};
{forward, NextName, NextRequestInfo, NextRequest,
NextTimeout, NextPriority, NewStateAPI} ->
?LOG(DebugLevel,
"forward ~p to ~p ~p",
[Name, NextName, {NextRequestInfo, NextRequest}]),
{forward, NextName, NextRequestInfo, NextRequest,
NextTimeout, NextPriority,
State#state{api_state = NewStateAPI}};
{noreply, NewStateAPI} ->
{noreply, State#state{api_state = NewStateAPI}};
{stop, Reason, NewStateAPI} ->
{stop, Reason, State#state{api_state = NewStateAPI}}
end.
cloudi_service_handle_info(Request,
#state{info_f = InfoF,
api_state = StateAPI} = State,
Dispatcher) ->
if
InfoF =:= undefined ->
?LOG_WARN("Unknown info \"~w\"", [Request]),
{noreply, State};
true ->
case InfoF(Request, StateAPI, Dispatcher) of
{noreply, NewStateAPI} ->
{noreply, State#state{api_state = NewStateAPI}};
{stop, Reason, NewStateAPI} ->
{stop, Reason, State#state{api_state = NewStateAPI}}
end
end.
cloudi_service_terminate(_Reason, _Timeout, undefined) ->
ok;
cloudi_service_terminate(Reason, Timeout,
#state{terminate_f = TerminateF,
api_state = StateAPI}) ->
if
TerminateF =:= undefined ->
ok;
true ->
(catch TerminateF(Reason, Timeout, StateAPI))
end,
ok.
%%%------------------------------------------------------------------------
%%% Private functions
%%%------------------------------------------------------------------------
subscribe_path([], _, _, _, Lookup, _) ->
Lookup;
subscribe_path([Format | Formats], Path, MethodSuffix, API,
Lookup, Dispatcher) ->
Suffix = Path ++ "." ++ Format ++ MethodSuffix,
ok = cloudi_service:subscribe(Dispatcher, Suffix),
NewAPI = API#api{format = erlang:list_to_atom(Format)},
NewLookup = trie:store(Suffix, NewAPI, Lookup),
subscribe_path(Formats, Path, MethodSuffix, API,
NewLookup, Dispatcher).
subscribe_paths('GET', Path, Formats, API, Lookup, Dispatcher) ->
subscribe_path(Formats, Path, "/get", API, Lookup, Dispatcher);
subscribe_paths('POST', Path, Formats, API, Lookup, Dispatcher) ->
subscribe_path(Formats, Path, "/post", API, Lookup, Dispatcher);
subscribe_paths('PUT', Path, Formats, API, Lookup, Dispatcher) ->
subscribe_path(Formats, Path, "/put", API, Lookup, Dispatcher);
subscribe_paths('DELETE', Path, Formats, API, Lookup, Dispatcher) ->
subscribe_path(Formats, Path, "/delete", API, Lookup, Dispatcher);
subscribe_paths('HEAD', Path, Formats, API, Lookup, Dispatcher) ->
subscribe_path(Formats, Path, "/head", API, Lookup, Dispatcher);
subscribe_paths('OPTIONS', Path, Formats, API, Lookup, Dispatcher) ->
subscribe_path(Formats, Path, "/options", API, Lookup, Dispatcher);
subscribe_paths('PATCH', Path, Formats, API, Lookup, Dispatcher) ->
subscribe_path(Formats, Path, "/patch", API, Lookup, Dispatcher);
subscribe_paths('TRACE', Path, Formats, API, Lookup, Dispatcher) ->
subscribe_path(Formats, Path, "/trace", API, Lookup, Dispatcher);
subscribe_paths('CONNECT', Path, Formats, API, Lookup, Dispatcher) ->
subscribe_path(Formats, Path, "/connect", API, Lookup, Dispatcher);
subscribe_paths(Method, Path, Formats, API, Lookup, Dispatcher)
when is_list(Method) ->
MethodSuffix = [$/ | cloudi_string:lowercase(Method)],
subscribe_path(Formats, Path, MethodSuffix, API, Lookup, Dispatcher).
response_info_headers(ResponseInfo0, Name, Format,
ContentTypes, ContentDisposition)
when is_list(ResponseInfo0) ->
{AttachmentGuess, ContentType} = maps:get(Format, ContentTypes),
ResponseInfo1 = if
(ContentDisposition =:= true) andalso
(AttachmentGuess =:= attachment) ->
case lists:keyfind(<<"content-disposition">>, 1,
ResponseInfo0) of
false ->
FilePath = cloudi_string:beforer($/, Name),
ContentDispositionValue = erlang:iolist_to_binary(
["attachment; filename=\"",
filename:basename(FilePath), "\""]),
[{<<"content-disposition">>,
ContentDispositionValue} | ResponseInfo0];
{_, _} ->
ResponseInfo0
end;
true ->
ResponseInfo0
end,
ResponseInfoN = case lists:keyfind(<<"content-type">>, 1,
ResponseInfo0) of
false ->
[{<<"content-type">>, ContentType} | ResponseInfo1];
{_, _} ->
ResponseInfo1
end,
ResponseInfoN;
response_info_headers(ResponseInfo, _, _, _, _) ->
ResponseInfo.