Custom Plugins and CORS

In the Plugins chapter we saw how Nova's built-in plugins work. Now let's build custom plugins and set up CORS for our blog API.

The nova_plugin behaviour

Every callback in nova_plugin is optional — implement only what you need. A plugin registered as pre_request must export pre_request/4; one registered as post_request must export post_request/4.

Request callbacks

-callback pre_request(Req, Env, Options, State) ->
    {ok, Req, State} |      %% Continue to the next plugin
    {break, Req, State} |   %% Skip remaining plugins, go to controller
    {stop, Req, State} |    %% Stop entirely, plugin handles the response
    {error, Reason}.        %% Trigger a 500 error

-callback post_request(Req, Env, Options, State) ->
    {ok, Req, State} |
    {break, Req, State} |
    {stop, Req, State} |
    {error, Reason}.

-callback plugin_info() ->
    #{title := binary(), version := binary(), url := binary(),
      authors := [binary()], description := binary(),
      options => [{atom(), binary()}]}.

Lifecycle callbacks: init/0 and stop/1

Two optional callbacks manage global, long-lived state that persists across requests:

-callback init() -> State :: any().
-callback stop(State :: any()) -> ok.

init/0 is called once when the plugin is loaded. The state it returns is passed as the State argument to every pre_request/4 and post_request/4 call. stop/1 is called when the application shuts down and receives the current state for cleanup.

This is useful when a plugin needs a long-lived resource — an ETS table, a connection pool reference, or a background process:

-module(blog_stats_plugin).
-behaviour(nova_plugin).

-export([init/0,
         stop/1,
         pre_request/4,
         post_request/4,
         plugin_info/0]).

init() ->
    Tab = ets:new(request_stats, [public, set]),
    ets:insert(Tab, {total_requests, 0}),
    #{table => Tab}.

stop(#{table := Tab}) ->
    ets:delete(Tab),
    ok.

pre_request(Req, _Env, _Options, #{table := Tab} = State) ->
    ets:update_counter(Tab, total_requests, 1),
    {ok, Req, State}.

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

plugin_info() ->
    #{title => <<"blog_stats_plugin">>,
      version => <<"1.0.0">>,
      url => <<"https://github.com/novaframework/nova">>,
      authors => [<<"Blog">>],
      description => <<"Tracks total request count in ETS">>}.

Without init/0, the plugin state starts as undefined. Without stop/1, no cleanup runs on shutdown.

Example: Request logger

A plugin that logs every request with method, path, and response time.

Create src/plugins/blog_logger_plugin.erl:

-module(blog_logger_plugin).
-behaviour(nova_plugin).

-include_lib("kernel/include/logger.hrl").

-export([pre_request/4,
         post_request/4,
         plugin_info/0]).

pre_request(Req, _Env, _Options, State) ->
    StartTime = erlang:monotonic_time(millisecond),
    {ok, Req#{start_time => StartTime}, State}.

post_request(Req, _Env, _Options, State) ->
    StartTime = maps:get(start_time, Req, 0),
    Duration = erlang:monotonic_time(millisecond) - StartTime,
    Method = cowboy_req:method(Req),
    Path = cowboy_req:path(Req),
    ?LOG_INFO("~s ~s completed in ~pms", [Method, Path, Duration]),
    {ok, Req, State}.

plugin_info() ->
    {<<"blog_logger_plugin">>,
     <<"1.0.0">>,
     <<"Blog">>,
     <<"Logs request method, path and duration">>,
     []}.

Register it as both pre-request and post-request in sys.config:

{plugins, [
    {pre_request, nova_request_plugin, #{decode_json_body => true,
                                          read_urlencoded_body => true}},
    {pre_request, blog_logger_plugin, #{}},
    {post_request, blog_logger_plugin, #{}}
]}

Output:

[info] GET /api/posts completed in 3ms
[info] POST /api/posts completed in 12ms

Example: Rate limiter

A plugin that limits requests per IP address using ETS:

-module(blog_rate_limit_plugin).
-behaviour(nova_plugin).

-export([pre_request/4,
         post_request/4,
         plugin_info/0]).

pre_request(Req, _Env, Options, State) ->
    MaxRequests = maps:get(max_requests, Options, 100),
    WindowMs = maps:get(window_ms, Options, 60000),
    {IP, _Port} = cowboy_req:peer(Req),
    Key = {rate_limit, IP},
    Now = erlang:monotonic_time(millisecond),
    case ets:lookup(blog_rate_limits, Key) of
        [{Key, Count, WindowStart}] when Now - WindowStart < WindowMs ->
            if Count >= MaxRequests ->
                    Reply = cowboy_req:reply(429,
                        #{<<"content-type">> => <<"application/json">>},
                        <<"{\"error\":\"too many requests\"}">>,
                        Req),
                    {stop, Reply, State};
               true ->
                    ets:update_element(blog_rate_limits, Key, {2, Count + 1}),
                    {ok, Req, State}
            end;
        _ ->
            ets:insert(blog_rate_limits, {Key, 1, Now}),
            {ok, Req, State}
    end.

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

plugin_info() ->
    {<<"blog_rate_limit_plugin">>,
     <<"1.0.0">>,
     <<"Blog">>,
     <<"Simple IP-based rate limiting">>,
     [max_requests, window_ms]}.

Create the ETS table on application start in src/blog_app.erl:

start(_StartType, _StartArgs) ->
    ets:new(blog_rate_limits, [named_table, public, set]),
    blog_sup:start_link().

When the limit is exceeded, the plugin returns {stop, Reply, State} — a 429 response is sent and the controller is never called.

CORS

If your API is consumed by a frontend on a different domain, the browser blocks requests unless your server sends the right CORS (Cross-Origin Resource Sharing) headers. Nova includes a CORS plugin.

Using nova_cors_plugin

Add it to your plugin configuration:

{plugins, [
    {pre_request, nova_cors_plugin, #{allow_origins => <<"*">>}},
    {pre_request, nova_request_plugin, #{decode_json_body => true}}
]}

Warning

Using <<"*">> allows requests from any origin. For production, restrict this to your frontend's domain:

{pre_request, nova_cors_plugin, #{allow_origins => <<"https://myblog.com">>}}

The plugin adds CORS headers to every response and handles preflight OPTIONS requests automatically.

Per-route CORS

Apply CORS only to API routes:

routes(_Environment) ->
  [
    %% API routes with CORS
    #{prefix => "/api",
      plugins => [
          {pre_request, nova_cors_plugin, #{allow_origins => <<"https://myblog.com">>}},
          {pre_request, nova_request_plugin, #{decode_json_body => true}}
      ],
      routes => [
                 {"/posts", fun blog_posts_controller:index/1, #{methods => [get]}},
                 {"/posts", fun blog_posts_controller:create/1, #{methods => [post]}},
                 {"/posts/:id", fun blog_posts_controller:show/1, #{methods => [get]}},
                 {"/posts/:id", fun blog_posts_controller:update/1, #{methods => [put]}},
                 {"/posts/:id", fun blog_posts_controller:delete/1, #{methods => [delete]}}
                ]
    },

    %% HTML routes without CORS
    #{prefix => "",
      plugins => [
          {pre_request, nova_request_plugin, #{read_urlencoded_body => true}}
      ],
      routes => [
                 {"/login", fun blog_main_controller:login/1, #{methods => [get, post]}}
                ]
    }
  ].

When plugins is set on a route group, it overrides the global plugin configuration for those routes.

Custom CORS plugin

The built-in plugin hardcodes Allow-Headers and Allow-Methods to *. For more control:

-module(blog_cors_plugin).
-behaviour(nova_plugin).

-export([pre_request/4,
         post_request/4,
         plugin_info/0]).

pre_request(Req, _Env, Options, State) ->
    Origins = maps:get(allow_origins, Options, <<"*">>),
    Methods = maps:get(allow_methods, Options, <<"GET, POST, PUT, DELETE, OPTIONS">>),
    Headers = maps:get(allow_headers, Options, <<"Content-Type, Authorization">>),
    MaxAge = maps:get(max_age, Options, <<"86400">>),

    Req1 = cowboy_req:set_resp_header(<<"access-control-allow-origin">>, Origins, Req),
    Req2 = cowboy_req:set_resp_header(<<"access-control-allow-methods">>, Methods, Req1),
    Req3 = cowboy_req:set_resp_header(<<"access-control-allow-headers">>, Headers, Req2),
    Req4 = cowboy_req:set_resp_header(<<"access-control-max-age">>, MaxAge, Req3),

    Req5 = case maps:get(allow_credentials, Options, false) of
               true ->
                   cowboy_req:set_resp_header(
                       <<"access-control-allow-credentials">>, <<"true">>, Req4);
               false ->
                   Req4
           end,

    case cowboy_req:method(Req5) of
        <<"OPTIONS">> ->
            Reply = cowboy_req:reply(204, Req5),
            {stop, Reply, State};
        _ ->
            {ok, Req5, State}
    end.

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

plugin_info() ->
    {<<"blog_cors_plugin">>,
     <<"1.0.0">>,
     <<"Blog">>,
     <<"Configurable CORS plugin">>,
     [allow_origins, allow_methods, allow_headers, max_age, allow_credentials]}.

Configure with all options:

{pre_request, blog_cors_plugin, #{
    allow_origins => <<"https://myblog.com">>,
    allow_methods => <<"GET, POST, PUT, DELETE">>,
    allow_headers => <<"Content-Type, Authorization, X-Request-ID">>,
    max_age => <<"3600">>,
    allow_credentials => true
}}

Testing CORS

Verify headers with curl:

# Check preflight response
curl -v -X OPTIONS localhost:8080/api/posts \
  -H "Origin: https://myblog.com" \
  -H "Access-Control-Request-Method: POST"

# Check actual response headers
curl -v localhost:8080/api/posts \
  -H "Origin: https://myblog.com"

You should see the Access-Control-Allow-Origin header in the response.

Plugin return values

ReturnEffect
{ok, Req, State}Continue to the next plugin or controller
{break, Req, State}Skip remaining plugins in this phase, go to controller
{stop, Req, State}Stop everything — plugin must have already sent a response
{error, Reason}Trigger a 500 error page

For the final chapter, let's add observability with OpenTelemetry.