%% @author Marc Worrell %% @copyright 2016-2022 Marc Worrell %% %% @doc Cowmachine: webmachine middleware for Cowboy/Zotonic %% @end %% Copyright 2016-2022 Marc Worrell %% %% Licensed under the Apache License, Version 2.0 (the "License"); %% you may not use this file except in compliance with the License. %% You may obtain a copy of the License at %% %% http://www.apache.org/licenses/LICENSE-2.0 %% %% Unless required by applicable law or agreed to in writing, software %% distributed under the License is distributed on an "AS IS" BASIS, %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. %% See the License for the specific language governing permissions and %% limitations under the License. -module(cowmachine). -author("Marc Worrell Result when Req :: cowboy_req:req(), Env :: cowboy_middleware:env(), Result :: {ok, Req, Env} | {stop, Req}. execute(Req, #{ cowmachine_controller := _Controller } = Env) -> ContextEnv = maps:get(cowmachine_context, Env, undefined), Context = cowmachine_req:init_context(Req, Env, ContextEnv), request(Context, #{}). %% @doc Handle a request, executes the cowmachine http states. Can be used by middleware %% functions to add some additional initialization of controllers or context. -spec request(Context, Options) -> Result when Context :: cowmachine_req:context(), Options :: map(), Req :: cowboy_req:req(), Env :: cowboy_middleware:env(), Result :: {ok, Req, Env} | {stop, Req}. request(Context, Options) -> Req = cowmachine_req:req(Context), Env = cowmachine_req:env(Context), Controller = maps:get(cowmachine_controller, Env), case request_1(Controller, Req, Env, Options, Context) of {upgrade, UpgradeFun, _StateResult, ContextResult} -> Controller:UpgradeFun(ContextResult); Other -> Other end. -spec request_1(Controller, Req, Env, Options, Context) -> Result when Controller :: atom(), Req :: cowboy_req:req(), Env :: cowboy_middleware:env(), Options :: map(), Context :: cowmachine_req:context(), Result :: {upgrade, UpgradeFun, State, Context} | cowboy_middleware:env() | any(), UpgradeFun :: atom(), State :: cmstate(). request_1(Controller, Req, Env, Options, Context) -> State = #cmstate{ controller = Controller, cache = #{}, options = Options }, Site = maps:get(site, Env, undefined), ReqResult = try EnvInit = cowmachine_req:init_env(Req, Env), Context1 = cowmachine_req:set_env(EnvInit, Context), case cowmachine_decision_core:handle_request(State, Context1) of {_Finish, _StateResult, ContextResult} -> ContextResult1 = case maps:get(on_handled, Options, undefined) of undefined -> ContextResult; Fun when is_function(Fun) -> Fun(ContextResult) end, cowmachine_response:send_response(ContextResult1); {upgrade, UpgradeFun, _StateResult, ContextResult} -> {upgrade, UpgradeFun, _StateResult, ContextResult} end catch throw:{stop_request, 500, {Reason, Stacktrace}} when is_list(Stacktrace) -> log(#{ at => ?AT, level => error, code => 500, text => "Stop request", reason => Reason, stack => Stacktrace}, Req), handle_stop_request(500, Site, undefined, Req, Env, State, Context); throw:{stop_request, 500, Reason} -> log(#{ at => ?AT, level => error, code => 500, text => "Stop request", reason => Reason}, Req), handle_stop_request(500, Site, {throw, Reason}, Req, Env, State, Context); throw:{stop_request, ResponseCode, Reason} when is_integer(ResponseCode), ResponseCode >= 400, ResponseCode < 500 -> handle_stop_request(ResponseCode, Site, {throw, Reason}, Req, Env, State, Context); throw:{stop_request, 500}:Stacktrace -> log(#{ at => ?AT, level => error, code => 500, text => "Stop request", stack => Stacktrace}, Req), handle_stop_request(500, Site, undefined, Req, Env, State, Context); throw:{stop_request, ResponseCode} when is_integer(ResponseCode), ResponseCode >= 400, ResponseCode < 500 -> handle_stop_request(ResponseCode, Site, undefined, Req, Env, State, Context); throw:{stop_request, ResponseCode} when is_integer(ResponseCode) -> {stop, cowboy_req:reply(ResponseCode, Req)}; throw:invalid_percent_encoding -> log(#{ at => ?AT, level => error, code => 400, text => "Illegal percent encoding" }, Req), {stop, cowboy_req:reply(400, Req)}; throw:invalid_qs_name -> log(#{ at => ?AT, level => error, code => 400, text => "Illegal query argument name" }, Req), {stop, cowboy_req:reply(400, Req)}; throw:Reason:Stacktrace -> log(#{ at => ?AT, level => error, code => 500, text => "Unexpected throw", class => throw, reason => Reason, stack => Stacktrace}, Req), handle_stop_request(500, Site, {throw, {Reason, Stacktrace}}, Req, Env, State, Context); Class:Reason:Stacktrace -> log(#{ at => ?AT, level => error, code => 500, text => "Unexpected exception", class => Class, reason => Reason, stack => Stacktrace}, Req), handle_stop_request(500, Site, {throw, {Reason, Stacktrace}}, Req, Env, State, Context) end, ReqResult. % @todo add the error controller as an application env, if not defined then just terminate with the corresponding error code. -spec handle_stop_request(ResponseCode, Site, Reason, Req, Env, State, Context) -> Result when ResponseCode :: integer(), Site :: atom() | undefined, Reason :: any(), Req :: cowboy_req:req(), Env :: cowboy_middleware:env(), State :: cmstate(), Context :: cowmachine_req:context(), Result :: {ok, Req, Env} | {stop, Req}. handle_stop_request(ResponseCode, _Site, Reason, Req, Env, State, Context) -> State1 = State#cmstate{ controller = controller_http_error }, EnvInit = cowmachine_req:init_env(Req, Env), Context1 = cowmachine_req:set_env(EnvInit, Context), Context2 = cowmachine_req:set_metadata(controller_module_error, State#cmstate.controller, Context1), Context3 = cowmachine_req:set_metadata(http_status_code, ResponseCode, Context2), Context4 = cowmachine_req:set_metadata(error_reason, Reason, Context3), try {_Finish, _StateResult, ContextResult} = cowmachine_decision_core:handle_request(State1, Context4), ContextRespCode = cowmachine_req:set_response_code(ResponseCode, ContextResult), cowmachine_response:send_response(ContextRespCode) catch throw:{stop_request, Code, CReason} -> log(#{ at => ?AT, level => warning, text => "Stop request", code => Code, reason => CReason }, Req), {stop, cowboy_req:reply(Code, Req)}; Class:CReason:Stacktrace-> log(#{ at => ?AT, level => warning, text => "Unexpected exception", code => 500, class => Class, reason => CReason, stack => Stacktrace }, Req), {stop, cowboy_req:reply(500, Req)} end. %% %% Logging %% -spec log(Report) -> Result when Report :: map(), Result :: any(). log(#{ level := Level } = Report) -> log_report(Level, Report#{ in => cowmachine, node => node() }). -spec log(Report, Req) -> Result when Report :: map(), Req :: map(), Result :: any(). log(#{ level := Level } = Report, Req) when is_map(Req) -> Report1 = lists:foldl(fun({Key, Fun}, Acc) -> case Fun(Req) of undefined -> Acc; {ok, Val} -> Acc#{ Key => Val } end end, Report, [{src, fun src/1}, {dst, fun dst/1}, {path, fun path/1}]), log_report(Level, Report1#{ in => cowmachine, node => node() }). -spec log_report(LogLevel, Report) -> Result when LogLevel :: debug | info | notice | warning | error, Report :: map(), Result :: any(). log_report(debug, Report) when is_map(Report) -> ?LOG_DEBUG(Report); log_report(info, Report) when is_map(Report) -> ?LOG_INFO(Report); log_report(notice, Report) when is_map(Report) -> ?LOG_NOTICE(Report); log_report(warning, Report) when is_map(Report) -> ?LOG_WARNING(Report); log_report(error, Report) when is_map(Report) -> ?LOG_ERROR(Report). -spec src(IpInfo) -> Result when IpInfo :: #{ peer := {IP, Port} } | any(), Port :: integer(), IP :: tuple(), Result :: {ok, map()} | undefined. src(#{ peer := {IP, Port} }) -> {ok, ip_info(IP, Port)}; src(_) -> undefined. -spec dst(DstInfo) -> Result when DstInfo :: #{ sock := {IP, Port} } | #{ port := Port } | any(), IP :: tuple(), Port :: integer(), Result :: {ok, #{IPType => string(), port => Port}} | {ok, #{ port => Port }} | undefined, IPType :: ip4 | ip6. dst(#{ sock := {IP, Port} } ) -> {ok, ip_info(IP, Port)}; dst(#{ port := Port }) -> {ok, #{ port => Port }}; dst(_) -> undefined. -spec path(PathInfo) -> Result when PathInfo :: #{ path := Path } | any(), Path :: any(), Result :: {ok, Path} | undefined. path(#{ path := Path }) -> {ok, Path}; path(_) -> undefined. -spec ip_info(IP, Port) -> Result when IP :: tuple(), Port :: integer(), IPType :: ip4 | ip6, Result :: #{IPType => string(), port => Port}. ip_info(IP, Port) -> IPType = case tuple_size(IP) of 4 -> ip4; 8 -> ip6 end, #{IPType => inet_parse:ntoa(IP), port => Port}.