Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Plugin manager #287

Draft
wants to merge 10 commits into
base: master
Choose a base branch
from
44 changes: 36 additions & 8 deletions guides/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,60 @@ These can be used to create access logs, insert CORS headers or similar.

Plugins are used to handle things before and/or after a request. They are applied on all requests of a specified protocol.

A rule of thumb is that if you want to store a short-lived state (For the local request) then you could store that under the Env-object. If you want to store a long-lived state (For the global request) then you could store that under the State-object which is initialized in the optional `init/0` callback and (potentially) updated in each call to `pre_request/4` and `post_request/4`. The long-lived state is only applicable to the plugin-module while the local state belongs to the request and is distributed to each invoked plugin.

This is an example:

```erlang
-module(correlation_id).
-behaviour(nova_plugin).
-export([
pre_request/2,
post_request/2,
pre_request/4,
post_request/4,
plugin_info/0
]).

pre_request(Req, NovaState) ->
pre_request(Req, _Env, _Opts, State) ->
UUID = uuid:uuid_to_string(uuid:get_v4()),
{ok, cowboy_req:set_resp_header(<<"x-correlation-id">>, UUID, Req), NovaState}.
{ok, cowboy_req:set_resp_header(<<"x-correlation-id">>, UUID, Req), State}.

post_request(Req, NovaState) ->
{ok, Req, NovaState}.
post_request(Req, _Env, _Opts, State) ->
{ok, Req, State}.

plugin_info() ->
{<<"Correlation plugin">>, <<"1.0.0">>, <<"Niclas Axelsson <[email protected]>">>,
<<"Example plugin for nova">>}.
#{
title => <<"nova_cors_plugin">>,
version => <<"0.2.0">>,
url => <<"https://github.com/novaframework/nova">>,
authors => [<<"Nova team <[email protected]">>],
description => <<"Add CORS headers to request">>,
options => [
]
}.
```

This plugin injects a UUID into the headers.


## Optional callbacks

There's two optional callbacks for a plugin that can be used for storing a global long-lived state. This can be useful if you want to keep track of something like a `pid` or similar.
The first callback is `init/0` and the second is `stop/1`. `init/0` is called when the plugin is loaded and `stop/1` is called when the plugin is unloaded (Usually when the nova-application is started/stopped).
Following example shows how we spawn a process and returns the pid in a map - this map will become our new state. When the plugin is stopped, `stop/1` will get invoked and we will send a stop message to the pid.

```erlang
init() ->
%% Setup some pids
Pid = spawn(fun() -> ok end),
#{my_pid => Pid}.

stop(State) ->
%% Stop the pids
Pid = maps:get(my_pid, State),
Pid ! stop,
ok.
```

Adding a plugin

Example:
Expand Down
76 changes: 60 additions & 16 deletions src/nova_plugin.erl
Original file line number Diff line number Diff line change
Expand Up @@ -30,23 +30,67 @@
-type request_type() :: pre_request | post_request.
-export_type([request_type/0]).

%% Define the callback functions for HTTP-plugins
-callback pre_request(State :: nova:state(), Options :: map()) ->
{ok, State0 :: nova:state()} |
{break, State0 :: nova:state()} |
{stop, State0 :: nova:state()} |
-type reply() :: {reply, Body :: binary()} |
{reply, Status :: integer(), Body :: binary()} |
{reply, Status :: integer(), Headers :: [{binary(), binary()}], Body :: binary()}.

%% @doc
%% Start function for the plugin. This function is called when the plugin is started
%% and will return a state that will be passed to the other functions during
%% the life cycle of the plugin. The state can be any term.
%% @end
-callback init() -> State :: nova:state().
-optional_callbacks([init/0]).

%% @doc
%% Stop function for the plugin. This function is called when the application is stopped.
%% It takes a state as argument and should return ok.
%% @end
-callback stop(State :: nova:state()) -> ok.
-optional_callbacks([stop/1]).

%% @doc
%% This function is called before the request is processed. It can modify the request
%% and the nova-state. It takes a state and a map of options as arguments and should return
%% either {ok, NewState}, {break, NewState}, {stop, NewState} or {error, Reason}.
%% @end
-callback pre_request(Req :: cowboy_req:req(), Env :: any(),
Options :: map(), PluginState :: any()) ->
{ok, Req0 :: cowboy_req:req(), NewState :: any()} |
{ok, Reply :: reply(), Req0 :: cowboy_req:req(), NewState :: any()} |
{break, Req0 :: cowboy_req:req(), NewState :: any()} |
{break, Reply :: reply(), Req0 :: cowboy_req:req(), NewState :: any()} |
{stop, Req0 :: cowboy_req:req(), NewState :: any()} |
{stop, Reply :: reply(), Req0 :: cowboy_req:req(), NewState :: any()} |
{error, Reason :: term()}.
-optional_callbacks([pre_request/2]).
-optional_callbacks([pre_request/4]).

-callback post_request(State :: nova:state(), Options :: map()) ->
{ok, State0 :: nova:state()} |
{break, State0 :: nova:state()} |
{stop, State0 :: nova:state()} |
%% @doc
%% This function is called after the request is processed. It can modify the request.
%% It takes a state and a map of options as arguments and should return
%% either {ok, NewState}, {break, NewState}, {stop, NewState} or {error, Reason}.
%% The state is only used if there's another plugin invoked after this one.
%% @end
-callback post_request(Req :: cowboy_req:req(), Env :: any(),
Options :: map(), PluginState :: any()) ->
{ok, Req0 :: cowboy_req:req(), NewState :: any()} |
{ok, Reply :: reply(), Req0 :: cowboy_req:req(), NewState :: any()} |
{break, Req0 :: cowboy_req:req(), NewState :: any()} |
{break, Reply :: reply(), Req0 :: cowboy_req:req(), NewState :: any()} |
{stop, Req0 :: cowboy_req:req(), NewState :: any()} |
{stop, Reply :: reply(), Req0 :: cowboy_req:req(), NewState :: any()} |
{error, Reason :: term()}.
-optional_callbacks([post_request/2]).
-optional_callbacks([post_request/4]).

-callback plugin_info() -> {Title :: binary(),
Version :: binary(),
Author :: binary(),
Description :: binary(),
Options :: [{Key :: atom(), OptionDescription :: binary()}]}.
%% @doc
%% This function should return information about the plugin. The information is used
%% in the documentation and in the plugin-listing.
%% @end
-callback plugin_info() -> #{title := binary(),
version := binary(),
url := binary(),
authors := [binary()],
description := binary(),
requires => [PluginName :: binary() |
{PluginName :: binary(), Version :: binary()}],
options => [{Key :: atom(), OptionDescription :: binary()}]}.
61 changes: 39 additions & 22 deletions src/nova_plugin_handler.erl
Original file line number Diff line number Diff line change
Expand Up @@ -22,28 +22,38 @@ execute(Req, Env) ->

run_plugins([], Callback, Req, Env) ->
{ok, Req, Env#{plugin_state => Callback}};
run_plugins([{Module, Options}|Tl], Callback, Req, Env) ->
Args = case proplists:get_value(Callback, Module:module_info(exports)) of
2 -> [Req, Options];
3 -> [Req, Env, Options];
_ -> {throw, bad_callback}
end,
try erlang:apply(Module, Callback, Args) of
{ok, Req0} ->
run_plugins(Tl, Callback, Req0, Env);
{ok, Reply, Req0} ->
Req1 = handle_reply(Reply, Req0),
run_plugins(Tl, Callback, Req1, Env);
{break, Req0} ->
{ok, Req0};
{break, Reply, Req0} ->
Req1 = handle_reply(Reply, Req0),
{ok, Req1};
{stop, Req0} ->
{stop, Req0};
{stop, Reply, Req0} ->
Req1 = handle_reply(Reply, Req0),
{stop, Req1}
run_plugins([{Module, Options}|Tl], Callback, Req, Env) when is_atom(Module) ->
run_plugins([{fun Module:Callback/1, Options}|Tl], Callback, Req, Env);
run_plugins([{Callback, Options}|Tl], CallbackType, Req, Env) when is_function(Callback) ->
%% Fetch state
State =
case nova_plugin_manager:get_state(Callback) of
{ok, State0} ->
State0;
{error, _Reason} ->
undefined
end,

try Callback(Req, Env, Options, State) of
Result ->
set_state(Callback, Result),
case Result of
{ok, Req0, _State0} ->
run_plugins(Tl, CallbackType, Req0, Env);
{ok, Reply, Req0, _State0} ->
Req1 = handle_reply(Reply, Req0),
run_plugins(Tl, CallbackType, Req1, Env);
{break, Req0, _State0} ->
{ok, Req0};
{break, Reply, Req0, _State0} ->
Req1 = handle_reply(Reply, Req0),
{ok, Req1};
{stop, Req0, _State0} ->
{stop, Req0};
{stop, Reply, Req0, _State0} ->
Req1 = handle_reply(Reply, Req0),
{stop, Req1}
end
catch
Class:Reason:Stacktrace ->
?LOG_ERROR(#{msg => <<"Plugin crashed">>, class => Class, reason => Reason, stacktrace => Stacktrace}),
Expand All @@ -63,3 +73,10 @@ handle_reply({reply, Status, Headers, Body}, Req) ->
Req1#{resp_status_code => Status};
handle_reply(_, Req) ->
Req.



set_state(Callback, Return) when is_function(Callback) ->
ReturnList = erlang:tuple_to_list(Return),
State = lists:last(ReturnList),
nova_plugin_manager:set_state(Callback, State).
Loading