Skip to content

Commit 2f0fb36

Browse files
authored
Merge pull request #7 from novaframework/docs/expand-plugins-chapter
docs: expand getting-started plugins chapter
2 parents 84ea3b7 + c528530 commit 2f0fb36

File tree

1 file changed

+224
-35
lines changed

1 file changed

+224
-35
lines changed

src/getting-started/plugins.md

Lines changed: 224 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,41 +6,13 @@ Plugins are Nova's middleware system. They run code before and after your contro
66

77
Every HTTP request flows through a pipeline:
88

9-
1. **Pre-request plugins** run in order (lowest priority number first)
9+
1. **Pre-request plugins** run in list definition order (first in the list runs first)
1010
2. The **controller** handles the request
11-
3. **Post-request plugins** run in order
11+
3. **Post-request plugins** run in list definition order
1212

1313
A plugin module implements the `nova_plugin` behaviour and exports `pre_request/4`, `post_request/4`, and `plugin_info/0`.
1414

15-
Here is an example — the `nova_correlation_plugin` that ships with Nova:
16-
17-
```erlang
18-
-module(nova_correlation_plugin).
19-
-behaviour(nova_plugin).
20-
21-
-export([pre_request/4,
22-
post_request/4,
23-
plugin_info/0]).
24-
25-
pre_request(Req0, _Env, Opts, State) ->
26-
CorrId = get_correlation_id(Req0, Opts),
27-
ok = update_logger_metadata(CorrId, Opts),
28-
Req1 = cowboy_req:set_resp_header(<<"X-Correlation-ID">>, CorrId, Req0),
29-
Req = Req1#{correlation_id => CorrId},
30-
{ok, Req, State}.
31-
32-
post_request(Req, _Env, _Opts, State) ->
33-
{ok, Req, State}.
34-
35-
plugin_info() ->
36-
#{title => <<"nova_correlation_plugin">>,
37-
version => <<"0.2.0">>,
38-
url => <<"https://github.com/novaframework/nova">>,
39-
authors => [<<"Nova team">>],
40-
description => <<"Add X-Correlation-ID headers to response">>}.
41-
```
42-
43-
The `pre_request` callback picks up or generates a correlation ID and adds it to both the response headers and the request map. `post_request` is a no-op here. The `State` argument is global plugin state — see [Custom Plugins](../going-further/plugins-cors.md) for details on managing it with `init/0` and `stop/1`.
15+
Each callback receives `(Req, Env, Options, State)` and returns `{ok, Req, State}` to pass control to the next plugin. Plugins can also enrich the request map — adding keys like `json`, `params`, or `correlation_id` — so that later plugins and controllers can use them.
4416

4517
## Configuring plugins
4618

@@ -56,7 +28,168 @@ Plugins are configured in `sys.config` under the `nova` application key:
5628

5729
Each plugin entry is a tuple: `{Phase, Module, Options}` where Phase is `pre_request` or `post_request`.
5830

59-
`nova_request_plugin` is a built-in plugin that handles request body decoding. The options map controls what it decodes.
31+
## nova_request_plugin
32+
33+
This built-in plugin handles request body decoding and query string parsing. It supports three options:
34+
35+
| Option | Type | Request map key | Description |
36+
|---|---|---|---|
37+
| `decode_json_body` | `true` | `json` | Decodes JSON request bodies |
38+
| `read_urlencoded_body` | `true` | `params` | Decodes URL-encoded form bodies |
39+
| `parse_qs` | `true \| list` | `parsed_qs` | Parses the URL query string |
40+
41+
### decode_json_body
42+
43+
When enabled, requests with `Content-Type: application/json` have their body decoded and placed in the `json` key:
44+
45+
```erlang
46+
{pre_request, nova_request_plugin, #{decode_json_body => true}}
47+
```
48+
49+
```erlang
50+
create(#{json := #{<<"title">> := Title}} = _Req) ->
51+
%% Use the decoded JSON body
52+
{json, #{created => Title}}.
53+
```
54+
55+
If the content type is `application/json` but the body is empty or malformed, the plugin returns a 400 response and the controller is never called.
56+
57+
`decode_json_body` is skipped for GET and DELETE requests since they typically have no body.
58+
59+
### read_urlencoded_body
60+
61+
When enabled, requests with `Content-Type: application/x-www-form-urlencoded` have their body parsed into a map under the `params` key:
62+
63+
```erlang
64+
{pre_request, nova_request_plugin, #{read_urlencoded_body => true}}
65+
```
66+
67+
```erlang
68+
login(#{params := #{<<"username">> := User, <<"password">> := Pass}} = _Req) ->
69+
%% Use the decoded form params
70+
...
71+
```
72+
73+
### parse_qs
74+
75+
Parses the URL query string (e.g. `?page=2&limit=10`). The value controls the format:
76+
77+
- `true` — returns a map in the `parsed_qs` key
78+
- `list` — returns a proplist of `{Key, Value}` tuples
79+
80+
```erlang
81+
{pre_request, nova_request_plugin, #{parse_qs => true}}
82+
```
83+
84+
```erlang
85+
index(#{parsed_qs := #{<<"page">> := Page}} = _Req) ->
86+
%% Use query params
87+
...
88+
```
89+
90+
### Combining options
91+
92+
You can enable all three at once:
93+
94+
```erlang
95+
{pre_request, nova_request_plugin, #{
96+
decode_json_body => true,
97+
read_urlencoded_body => true,
98+
parse_qs => true
99+
}}
100+
```
101+
102+
## nova_correlation_plugin
103+
104+
This plugin assigns a unique correlation ID to every request — essential for tracing requests across services in your logs.
105+
106+
```erlang
107+
{pre_request, nova_correlation_plugin, #{
108+
request_correlation_header => <<"x-correlation-id">>,
109+
logger_metadata_key => correlation_id
110+
}}
111+
```
112+
113+
| Option | Default | Description |
114+
|---|---|---|
115+
| `request_correlation_header` | *(none — always generates)* | Header to read an existing correlation ID from. Cowboy lowercases all header names. |
116+
| `logger_metadata_key` | `<<"correlation-id">>` | Key set in OTP logger process metadata |
117+
118+
The plugin:
119+
120+
1. Reads the correlation ID from the configured header, or generates a v4 UUID if missing
121+
2. Sets the ID in OTP logger metadata (so all log messages for this request include it)
122+
3. Adds an `x-correlation-id` response header
123+
4. Stores the ID in the request map as `correlation_id`
124+
125+
Access it in your controller:
126+
127+
```erlang
128+
show(#{correlation_id := CorrId} = _Req) ->
129+
logger:info("Handling request ~s", [CorrId]),
130+
...
131+
```
132+
133+
## nova_csrf_plugin
134+
135+
This plugin provides CSRF protection using the synchronizer token pattern. It generates a random token per session and validates it on state-changing requests.
136+
137+
```erlang
138+
{pre_request, nova_csrf_plugin, #{}}
139+
```
140+
141+
| Option | Default | Description |
142+
|---|---|---|
143+
| `field_name` | `<<"_csrf_token">>` | Form field name to check |
144+
| `header_name` | `<<"x-csrf-token">>` | Header name to check (for AJAX) |
145+
| `session_key` | `<<"_csrf_token">>` | Key used to store the token in the session |
146+
| `excluded_paths` | `[]` | List of path prefixes to skip protection for |
147+
148+
### How it works
149+
150+
- **Safe methods** (GET, HEAD, OPTIONS): The plugin ensures a CSRF token exists in the session and injects it into the request map as `csrf_token`.
151+
- **Unsafe methods** (POST, PUT, PATCH, DELETE): The plugin reads the submitted token from the form field or header and validates it against the session token. If the token is missing or doesn't match, the request is rejected with a 403 response.
152+
153+
### Template integration
154+
155+
In your ErlyDTL templates, include the token in forms as a hidden field:
156+
157+
```html
158+
<form method="post" action="/login">
159+
<input type="hidden" name="_csrf_token" value="{{ csrf_token }}" />
160+
<!-- rest of form -->
161+
<button type="submit">Log in</button>
162+
</form>
163+
```
164+
165+
The `csrf_token` variable is available because the plugin adds it to the request map, and Nova passes request map values to templates as template variables.
166+
167+
For AJAX requests, send the token in a header instead:
168+
169+
```javascript
170+
fetch('/api/resource', {
171+
method: 'POST',
172+
headers: {
173+
'X-CSRF-Token': csrfToken,
174+
'Content-Type': 'application/json'
175+
},
176+
body: JSON.stringify(data)
177+
});
178+
```
179+
180+
### Excluding API paths
181+
182+
If you have API routes that use a different authentication scheme (e.g. bearer tokens), exclude them from CSRF checks:
183+
184+
```erlang
185+
{pre_request, nova_csrf_plugin, #{
186+
excluded_paths => [<<"/api/">>]
187+
}}
188+
```
189+
190+
```admonish warning
191+
`nova_request_plugin` must run **before** `nova_csrf_plugin` so that form params are parsed into the `params` key. Plugin order matters — list `nova_request_plugin` first.
192+
```
60193

61194
## Setting up for our login form
62195

@@ -74,11 +207,67 @@ With this setting, form POST data is decoded and placed in the `params` key of t
74207
You can enable multiple decoders at once. We will add `decode_json_body => true` later when we build our [JSON API](../building-api/json-api.md).
75208
```
76209

77-
## Built-in plugins
210+
## Per-route plugins
211+
212+
So far we've configured plugins globally in `sys.config`. You can also set plugins per route group by adding a `plugins` key to the group map in your router:
213+
214+
```erlang
215+
routes(_Environment) ->
216+
[
217+
#{prefix => "/api",
218+
plugins => [
219+
{pre_request, nova_request_plugin, #{decode_json_body => true}}
220+
],
221+
routes => [
222+
{"/posts", fun blog_posts_controller:index/1, #{methods => [get]}}
223+
]
224+
},
225+
#{prefix => "",
226+
plugins => [
227+
{pre_request, nova_request_plugin, #{read_urlencoded_body => true}}
228+
],
229+
routes => [
230+
{"/login", fun blog_main_controller:login/1, #{methods => [get, post]}}
231+
]
232+
}
233+
].
234+
```
78235

79-
Nova ships with several plugins. See the [Nova documentation](https://hexdocs.pm/nova/plugins.html) for the full list.
236+
When `plugins` is set on a route group, it **overrides** the global plugin configuration for those routes. This lets you use JSON decoding for API routes and form decoding for HTML routes without conflict.
237+
238+
See [Custom Plugins and CORS](../going-further/plugins-cors.md) for more examples, including per-route CORS.
239+
240+
## Built-in plugins summary
241+
242+
| Plugin | Phase | Purpose | Key request map additions |
243+
|---|---|---|---|
244+
| `nova_request_plugin` | pre_request | Decodes JSON/form bodies, parses query strings | `json`, `params`, `parsed_qs` |
245+
| `nova_correlation_plugin` | pre_request | Assigns correlation IDs for request tracing | `correlation_id` |
246+
| `nova_csrf_plugin` | pre_request | CSRF protection via synchronizer token | `csrf_token` |
247+
| `nova_cors_plugin` | pre_request | Adds CORS headers, handles preflight requests | *(headers only)* |
248+
249+
A realistic configuration using multiple plugins:
250+
251+
```erlang
252+
{nova, [
253+
{plugins, [
254+
{pre_request, nova_correlation_plugin, #{
255+
request_correlation_header => <<"x-correlation-id">>,
256+
logger_metadata_key => correlation_id
257+
}},
258+
{pre_request, nova_request_plugin, #{
259+
decode_json_body => true,
260+
read_urlencoded_body => true,
261+
parse_qs => true
262+
}},
263+
{pre_request, nova_csrf_plugin, #{
264+
excluded_paths => [<<"/api/">>]
265+
}}
266+
]}
267+
]}
268+
```
80269

81-
For now, the key one is `nova_request_plugin` — it handles JSON body decoding, URL-encoded body decoding, and multipart uploads.
270+
Ordering matters: `nova_correlation_plugin` runs first so all subsequent log messages include the correlation ID. `nova_request_plugin` runs before `nova_csrf_plugin` so form params are available for token validation.
82271

83272
---
84273

0 commit comments

Comments
 (0)