-
-
-```
-
-**`notes_new.dtl`** — Create a new note:
-
-```html
-
-New Note
-
-
New Note
-
- Back
-
-
-```
-
-**`notes_edit.dtl`** — Edit a note:
-
-```html
-
-Edit Note
-
-
Edit Note
-
- Back
-
-
-```
-
-## Routing
-
-The full router with all route groups:
-
-```erlang
--module(my_first_nova_router).
--behaviour(nova_router).
-
--export([routes/1]).
-
-routes(_Environment) ->
- [
- %% Public routes
- #{prefix => "",
- security => false,
- routes => [
- {"/login", fun my_first_nova_main_controller:login/1, #{methods => [get]}},
- {"/heartbeat", fun(_) -> {status, 200} end, #{methods => [get]}},
- {"/ws", my_first_nova_ws_handler, #{protocol => ws}}
- ]
- },
-
- %% Auth endpoint
- #{prefix => "",
- security => fun my_first_nova_auth:username_password/1,
- routes => [
- {"/", fun my_first_nova_main_controller:index/1, #{methods => [post]}}
- ]
- },
-
- %% HTML notes (with session auth)
- #{prefix => "/notes",
- security => fun my_first_nova_auth:session_auth/1,
- routes => [
- {"/", fun my_first_nova_notes_controller:index/1, #{methods => [get]}},
- {"/new", fun my_first_nova_notes_controller:new/1, #{methods => [get]}},
- {"/", fun my_first_nova_notes_controller:create/1, #{methods => [post]}},
- {"/:id/edit", fun my_first_nova_notes_controller:edit/1, #{methods => [get]}},
- {"/:id", fun my_first_nova_notes_controller:update/1, #{methods => [post]}},
- {"/:id/delete", fun my_first_nova_notes_controller:delete/1, #{methods => [post]}}
- ]
- },
-
- %% JSON API (no auth for simplicity)
- #{prefix => "/api",
- security => false,
- routes => [
- {"/notes", fun my_first_nova_notes_api_controller:index/1, #{methods => [get]}},
- {"/notes/:id", fun my_first_nova_notes_api_controller:show/1, #{methods => [get]}},
- {"/notes", fun my_first_nova_notes_api_controller:create/1, #{methods => [post]}},
- {"/notes/:id", fun my_first_nova_notes_api_controller:update/1, #{methods => [put]}},
- {"/notes/:id", fun my_first_nova_notes_api_controller:delete/1, #{methods => [delete]}}
- ]
- }
- ].
-```
-
-## Testing it
-
-Start the application and test the JSON API:
-
-```shell
-# Create a note
-curl -s -X POST localhost:8080/api/notes \
- -H "Content-Type: application/json" \
- -d '{"title": "My first note", "body": "Hello from Nova!", "author": "Alice"}'
-
-# List all notes
-curl -s localhost:8080/api/notes
-
-# Get a specific note
-curl -s localhost:8080/api/notes/1
-
-# Update a note
-curl -s -X PUT localhost:8080/api/notes/1 \
- -H "Content-Type: application/json" \
- -d '{"title": "Updated title", "body": "Updated body"}'
-
-# Delete a note
-curl -s -X DELETE localhost:8080/api/notes/1
-```
-
-For the HTML interface, go to `localhost:8080/login`, log in, then navigate to `localhost:8080/notes`.
-
-## What we built
-
-- **Router** with four route groups: public, auth, HTML notes with security, and JSON API
-- **Controllers** for both HTML and JSON responses
-- **Views** using ErlyDTL templates with loops and variable interpolation
-- **Security** module for authentication
-- **Database** persistence with PostgreSQL
-- **Repository** module for clean data access
-
-This is the pattern that scales. As your application grows, you add more repos, controllers, views, and route groups.
-
----
-
-Our application works, but what happens when something goes wrong? Let's add proper [error handling](error-handling.md).
diff --git a/src/building-app/sub-applications.md b/src/building-app/sub-applications.md
deleted file mode 100644
index cdfce5c..0000000
--- a/src/building-app/sub-applications.md
+++ /dev/null
@@ -1,116 +0,0 @@
-# Sub-Applications
-
-Nova applications are composable — you can mount one Nova app inside another. This lets you use third-party Nova packages or split a large application into independent modules that each manage their own routes.
-
-## Adding a sub-application
-
-We will use [Nova Admin](https://github.com/novaframework/nova_admin) as an example. It provides an observer-like web interface for monitoring your running Erlang node.
-
-### Adding the dependency
-
-In `rebar.config`:
-
-```erlang
-{deps, [
- nova,
- {flatlog, "0.1.2"},
- pgo,
- {nova_admin, ".*", {git, "git@github.com:novaframework/nova_admin.git", {branch, "master"}}}
- ]}.
-```
-
-### Configuring the sub-application
-
-Tell Nova about the new application in `dev_sys.config.src`. Nova has a `nova_apps` configuration that you set in your own application's environment:
-
-```erlang
-{my_first_nova, [
- {nova_apps, [
- {nova_admin, #{prefix => "/admin"}}
- ]}
-]}
-```
-
-The `prefix` option means all routes from `nova_admin` are mounted under `/admin`. So if nova_admin has a route for `/`, it becomes `/admin/` in your application.
-
-Your full `dev_sys.config.src` should look like:
-
-```erlang
-[
- {kernel, [
- {logger_level, debug},
- {logger, [
- {handler, default, logger_std_h,
- #{formatter => {flatlog, #{
- map_depth => 3,
- term_depth => 50,
- colored => true,
- template => [colored_start, "[\033[1m", level, "\033[0m",
- colored_start, "] ", msg, "\n", colored_end]
- }}}}
- ]}
- ]},
- {nova, [
- {use_stacktrace, true},
- {environment, dev},
- {cowboy_configuration, #{port => 8080}},
- {dev_mode, true},
- {bootstrap_application, my_first_nova},
- {plugins, [
- {pre_request, nova_request_plugin, #{read_urlencoded_body => true}}
- ]}
- ]},
- {my_first_nova, [
- {nova_apps, [
- {nova_admin, #{prefix => "/admin"}}
- ]}
- ]}
-].
-```
-
-### How it works
-
-When Nova starts, it reads the `bootstrap_application` setting and looks for the `nova_apps` configuration in that application's environment. It compiles routes from all listed sub-applications and merges them into the routing tree.
-
-Each sub-application is a standalone Nova app with its own router module. Nova Admin has a `nova_admin_router.erl` that defines its own routes. When you set `prefix => "/admin"`, Nova prepends that prefix to all of nova_admin's routes.
-
-### Starting it up
-
-```shell
-rebar3 nova serve
-```
-
-Check the routes:
-
-```shell
-rebar3 nova routes
-```
-
-You should see nova_admin's routes mounted under `/admin`. Visit `localhost:8080/admin` to see the Nova Admin interface — it shows running processes, memory usage, and other information about your Erlang node.
-
-## Multiple sub-applications
-
-Mount multiple sub-applications, each with their own prefix:
-
-```erlang
-{my_first_nova, [
- {nova_apps, [
- {nova_admin, #{prefix => "/admin"}},
- {my_api_app, #{prefix => "/api"}}
- ]}
-]}
-```
-
-## Using sub-applications in your own projects
-
-The same pattern works for any Nova application:
-
-1. Add it as a dependency in `rebar.config`
-2. Add it to `nova_apps` in your sys.config with an optional prefix
-3. Start your application
-
-This is one of Nova's powerful features — you can compose applications from multiple Nova apps, each handling their own piece of the system.
-
----
-
-Our application is feature-complete. Let's prepare it for production with [deployment](deployment.md).
diff --git a/src/data-and-testing/database-integration.md b/src/data-and-testing/database-integration.md
deleted file mode 100644
index 1a2ad40..0000000
--- a/src/data-and-testing/database-integration.md
+++ /dev/null
@@ -1,248 +0,0 @@
-# Database Integration
-
-Nova does not include a built-in ORM or database layer — by design, you choose whatever database and driver fits your project. In this chapter we will integrate PostgreSQL using [pgo](https://github.com/erleans/pgo) and structure our data access code.
-
-## Why pgo?
-
-pgo is a PostgreSQL client for Erlang with built-in connection pooling. You don't need a separate pool library — configure a pool in `sys.config`, call `pgo:query/2`, and pgo handles checkout, checkin, and reconnection for you.
-
-## Adding the dependency
-
-Add `pgo` to `rebar.config`:
-
-```erlang
-{deps, [
- nova,
- {flatlog, "0.1.2"},
- pgo
- ]}.
-```
-
-Also add it to your application dependencies in `src/my_first_nova.app.src`:
-
-```erlang
-{applications,
- [kernel,
- stdlib,
- nova,
- pgo
- ]},
-```
-
-## Database configuration
-
-Configure the pgo pool in `dev_sys.config.src`:
-
-```erlang
-{pgo, [
- {pools, [
- {default, #{
- pool_size => 10,
- host => "localhost",
- port => 5432,
- database => "my_first_nova_dev",
- user => "postgres",
- password => "postgres"
- }}
- ]}
-]}
-```
-
-The `default` pool is used automatically when you call `pgo:query/2` without specifying a pool name. That is it for setup — no gen_server, no supervision tree changes. pgo starts its own pool supervisor when the application boots.
-
-```admonish info title="Database setup"
-Create the database before continuing:
-
-~~~sql
-CREATE DATABASE my_first_nova_dev;
-\c my_first_nova_dev
-
-CREATE TABLE users (
- id SERIAL PRIMARY KEY,
- name VARCHAR(255) NOT NULL,
- email VARCHAR(255) NOT NULL UNIQUE,
- inserted_at TIMESTAMP DEFAULT NOW(),
- updated_at TIMESTAMP DEFAULT NOW()
-);
-~~~
-```
-
-## Creating a repository module
-
-Create `src/my_first_nova_user_repo.erl` to encapsulate data access:
-
-```erlang
--module(my_first_nova_user_repo).
--export([
- all/0,
- get/1,
- create/2,
- update/3,
- delete/1
- ]).
-
-all() ->
- case pgo:query("SELECT id, name, email FROM users ORDER BY id") of
- #{rows := Rows} ->
- {ok, [row_to_map(Row) || Row <- Rows]};
- {error, Reason} ->
- {error, Reason}
- end.
-
-get(Id) ->
- case pgo:query("SELECT id, name, email FROM users WHERE id = $1", [Id]) of
- #{rows := [Row]} ->
- {ok, row_to_map(Row)};
- #{rows := []} ->
- {error, not_found};
- {error, Reason} ->
- {error, Reason}
- end.
-
-create(Name, Email) ->
- case pgo:query("INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email",
- [Name, Email]) of
- #{rows := [Row]} ->
- {ok, row_to_map(Row)};
- {error, Reason} ->
- {error, Reason}
- end.
-
-update(Id, Name, Email) ->
- case pgo:query("UPDATE users SET name = $1, email = $2 WHERE id = $3 RETURNING id, name, email",
- [Name, Email, Id]) of
- #{rows := [Row]} ->
- {ok, row_to_map(Row)};
- #{rows := []} ->
- {error, not_found};
- {error, Reason} ->
- {error, Reason}
- end.
-
-delete(Id) ->
- case pgo:query("DELETE FROM users WHERE id = $1", [Id]) of
- #{command := delete, num_rows := 1} -> ok;
- #{num_rows := 0} -> {error, not_found};
- {error, Reason} -> {error, Reason}
- end.
-
-row_to_map({Id, Name, Email}) ->
- #{id => Id, name => Name, email => Email}.
-```
-
-`pgo:query/1` and `pgo:query/2` handle connection pooling transparently. Results are maps with a `rows` key containing tuples.
-
-## Using the repo in controllers
-
-Update the API controller to use real data:
-
-```erlang
--module(my_first_nova_api_controller).
--export([
- index/1,
- show/1,
- create/1,
- update/1,
- delete/1
- ]).
-
-index(_Req) ->
- {ok, Users} = my_first_nova_user_repo:all(),
- {json, #{users => Users}}.
-
-show(#{bindings := #{<<"id">> := Id}}) ->
- case my_first_nova_user_repo:get(binary_to_integer(Id)) of
- {ok, User} ->
- {json, User};
- {error, not_found} ->
- {status, 404, #{}, #{error => <<"user not found">>}}
- end.
-
-create(#{params := #{<<"name">> := Name, <<"email">> := Email}}) ->
- case my_first_nova_user_repo:create(Name, Email) of
- {ok, User} ->
- {json, 201, #{}, User};
- {error, Reason} ->
- {status, 422, #{}, #{error => list_to_binary(io_lib:format("~p", [Reason]))}}
- end;
-create(_Req) ->
- {status, 422, #{}, #{error => <<"name and email required">>}}.
-
-update(#{bindings := #{<<"id">> := Id},
- params := #{<<"name">> := Name, <<"email">> := Email}}) ->
- case my_first_nova_user_repo:update(binary_to_integer(Id), Name, Email) of
- {ok, User} ->
- {json, User};
- {error, not_found} ->
- {status, 404, #{}, #{error => <<"user not found">>}}
- end.
-
-delete(#{bindings := #{<<"id">> := Id}}) ->
- case my_first_nova_user_repo:delete(binary_to_integer(Id)) of
- ok ->
- {status, 204};
- {error, not_found} ->
- {status, 404, #{}, #{error => <<"user not found">>}}
- end.
-```
-
-Add the new routes:
-
-```erlang
-#{prefix => "/api",
- security => false,
- routes => [
- {"/users", fun my_first_nova_api_controller:index/1, #{methods => [get]}},
- {"/users/:id", fun my_first_nova_api_controller:show/1, #{methods => [get]}},
- {"/users", fun my_first_nova_api_controller:create/1, #{methods => [post]}},
- {"/users/:id", fun my_first_nova_api_controller:update/1, #{methods => [put]}},
- {"/users/:id", fun my_first_nova_api_controller:delete/1, #{methods => [delete]}}
- ]
-}
-```
-
-## Named pools
-
-For multiple databases or separate workloads:
-
-```erlang
-{pgo, [
- {pools, [
- {default, #{pool_size => 10, host => "localhost", database => "my_first_nova_dev",
- user => "postgres", password => "postgres"}},
- {readonly, #{pool_size => 5, host => "localhost", database => "my_first_nova_dev",
- user => "readonly_user", password => "readonly_pass"}}
- ]}
-]}
-```
-
-Query a specific pool:
-
-```erlang
-pgo:query(readonly, "SELECT count(*) FROM users", []).
-```
-
-## Transactions
-
-pgo supports transactions — if any query fails, the whole thing rolls back:
-
-```erlang
-pgo:transaction(fun() ->
- pgo:query("INSERT INTO users (name, email) VALUES ($1, $2)", [Name, Email]),
- pgo:query("INSERT INTO audit_log (action, target) VALUES ($1, $2)", [<<"create_user">>, Email])
-end).
-```
-
-## Other databases
-
-The repository pattern works for any database. Popular Erlang database drivers:
-
-- **PostgreSQL**: `pgo`, `epgsql`
-- **MySQL**: `mysql-otp`
-- **Redis**: `eredis`
-- **Mnesia**: built into OTP, no external dependency
-- **SQLite**: `esqlite`
-
----
-
-Now that we have data persistence, let's learn how to [test](testing.md) our Nova application.
diff --git a/src/data-and-testing/testing.md b/src/data-and-testing/testing.md
deleted file mode 100644
index 2e47a27..0000000
--- a/src/data-and-testing/testing.md
+++ /dev/null
@@ -1,226 +0,0 @@
-# Testing
-
-Nova applications can be tested with Erlang's built-in frameworks: EUnit for unit tests and Common Test for integration tests. The [nova_test](https://github.com/novaframework/nova_test) library adds helpers: a request builder for unit testing controllers, an HTTP client for integration tests, and assertion macros.
-
-## Adding nova_test
-
-Add `nova_test` as a test dependency in `rebar.config`:
-
-```erlang
-{profiles, [
- {test, [
- {deps, [
- {nova_test, "0.1.0"}
- ]}
- ]}
-]}.
-```
-
-## EUnit — Unit testing controllers
-
-Nova controllers are regular Erlang functions that receive a request map and return a tuple. The `nova_test_req` module builds well-formed request maps so you don't have to construct them by hand.
-
-Create `test/my_first_nova_api_controller_tests.erl`:
-
-```erlang
--module(my_first_nova_api_controller_tests).
--include_lib("nova_test/include/nova_test.hrl").
-
-show_existing_user_test() ->
- Req = nova_test_req:new(get, "/users/1"),
- Req1 = nova_test_req:with_bindings(#{<<"id">> => <<"1">>}, Req),
- Result = my_first_nova_api_controller:show(Req1),
- ?assertJsonResponse(#{id := 1, name := _, email := _}, Result).
-
-show_missing_user_test() ->
- Req = nova_test_req:new(get, "/users/999999"),
- Req1 = nova_test_req:with_bindings(#{<<"id">> => <<"999999">>}, Req),
- Result = my_first_nova_api_controller:show(Req1),
- ?assertStatusResponse(404, Result).
-
-create_user_test() ->
- Req = nova_test_req:new(post, "/users"),
- Req1 = nova_test_req:with_json(#{<<"name">> => <<"Alice">>,
- <<"email">> => <<"alice@example.com">>}, Req),
- Result = my_first_nova_api_controller:create(Req1),
- ?assertJsonResponse(201, #{id := _}, Result).
-
-create_missing_params_test() ->
- Req = nova_test_req:new(post, "/users"),
- Req1 = nova_test_req:with_json(#{}, Req),
- Result = my_first_nova_api_controller:create(Req1),
- ?assertStatusResponse(422, Result).
-```
-
-### Request builder functions
-
-| Function | Purpose |
-|---|---|
-| `nova_test_req:new/2` | Create a request with method and path |
-| `nova_test_req:with_bindings/2` | Set path bindings (e.g. `#{<<"id">> => <<"1">>}`) |
-| `nova_test_req:with_json/2` | Set a JSON body (auto-encodes, sets content-type) |
-| `nova_test_req:with_header/3` | Add a request header |
-| `nova_test_req:with_query/2` | Set query string parameters |
-| `nova_test_req:with_body/2` | Set a raw body |
-| `nova_test_req:with_auth_data/2` | Set auth data (for testing authenticated controllers) |
-| `nova_test_req:with_peer/2` | Set the client peer address |
-
-Run EUnit tests with:
-
-```shell
-rebar3 eunit
-```
-
-## Testing without database dependency
-
-For pure unit tests, separate logic from data access:
-
-```erlang
--module(my_first_nova_request_tests).
--include_lib("nova_test/include/nova_test.hrl").
-
-parse_json_body_test() ->
- Req = nova_test_req:new(post, "/users"),
- Req1 = nova_test_req:with_json(#{<<"name">> => <<"Alice">>,
- <<"email">> => <<"alice@example.com">>}, Req),
- #{json := #{<<"name">> := Name, <<"email">> := Email}} = Req1,
- ?assertEqual(<<"Alice">>, Name),
- ?assertEqual(<<"alice@example.com">>, Email).
-
-parse_bindings_test() ->
- Req = nova_test_req:new(get, "/users/42"),
- Req1 = nova_test_req:with_bindings(#{<<"id">> => <<"42">>}, Req),
- #{bindings := #{<<"id">> := Id}} = Req1,
- ?assertEqual(42, binary_to_integer(Id)).
-```
-
-```admonish tip
-Use `-ifdef(TEST)` to export helper functions only in test builds:
-
-~~~erlang
--ifdef(TEST).
--export([row_to_map/1]).
--endif.
-~~~
-```
-
-## Common Test — Integration testing
-
-Common Test is better for full-stack tests where you need the application running. `nova_test` provides an HTTP client that handles startup and port discovery.
-
-You can scaffold a Common Test suite with the code generator:
-
-```shell
-rebar3 nova gen_test --name users
-===> Writing test/my_first_nova_users_controller_SUITE.erl
-```
-
-This creates a suite with test cases for each CRUD action that make HTTP requests against your running application. See [Code Generators](../developer-tools/code-generators.md) for the full details.
-
-Here is what a hand-written suite looks like using `nova_test`. Create `test/my_first_nova_api_SUITE.erl`:
-
-```erlang
--module(my_first_nova_api_SUITE).
--include_lib("common_test/include/ct.hrl").
--include_lib("nova_test/include/nova_test.hrl").
-
--export([
- all/0,
- init_per_suite/1,
- end_per_suite/1,
- test_get_users/1,
- test_create_user/1,
- test_get_user_not_found/1
- ]).
-
-all() ->
- [test_get_users,
- test_create_user,
- test_get_user_not_found].
-
-init_per_suite(Config) ->
- nova_test:start(my_first_nova, Config).
-
-end_per_suite(Config) ->
- nova_test:stop(Config).
-
-test_get_users(Config) ->
- {ok, Resp} = nova_test:get("/api/users", Config),
- ?assertStatus(200, Resp),
- ?assertJson(#{<<"users">> := [_ | _]}, Resp).
-
-test_create_user(Config) ->
- {ok, Resp} = nova_test:post("/api/users",
- #{json => #{<<"name">> => <<"Test User">>,
- <<"email">> => <<"test@example.com">>}},
- Config),
- ?assertStatus(201, Resp),
- ?assertJson(#{<<"name">> := <<"Test User">>}, Resp).
-
-test_get_user_not_found(Config) ->
- {ok, Resp} = nova_test:get("/api/users/999999", Config),
- ?assertStatus(404, Resp).
-```
-
-`nova_test:start/2` boots your application and discovers the port. All HTTP functions (`get`, `post`, `put`, `patch`, `delete`) accept a path, optional options, and the Config.
-
-### Assertion macros
-
-| Macro | Purpose |
-|---|---|
-| `?assertStatus(Code, Resp)` | Assert the HTTP status code |
-| `?assertJson(Pattern, Resp)` | Pattern-match the decoded JSON body |
-| `?assertBody(Expected, Resp)` | Assert the raw response body |
-| `?assertHeader(Name, Expected, Resp)` | Assert a response header value |
-
-Run Common Test suites with:
-
-```shell
-rebar3 ct
-```
-
-## Testing security modules
-
-Test your security functions directly:
-
-```erlang
--module(my_first_nova_auth_tests).
--include_lib("nova_test/include/nova_test.hrl").
-
-valid_login_test() ->
- Req = nova_test_req:new(post, "/login"),
- Req1 = nova_test_req:with_json(#{<<"username">> => <<"admin">>,
- <<"password">> => <<"password">>}, Req),
- ?assertMatch({true, #{authed := true, username := <<"admin">>}},
- my_first_nova_auth:username_password(Req1)).
-
-invalid_password_test() ->
- Req = nova_test_req:new(post, "/login"),
- Req1 = nova_test_req:with_json(#{<<"username">> => <<"admin">>,
- <<"password">> => <<"wrong">>}, Req),
- ?assertEqual(false, my_first_nova_auth:username_password(Req1)).
-
-missing_params_test() ->
- Req = nova_test_req:new(post, "/login"),
- ?assertEqual(false, my_first_nova_auth:username_password(Req)).
-```
-
-## Test structure
-
-```
-test/
-├── my_first_nova_api_controller_tests.erl %% EUnit
-├── my_first_nova_auth_tests.erl %% EUnit
-├── my_first_nova_request_tests.erl %% EUnit
-└── my_first_nova_api_SUITE.erl %% Common Test
-```
-
-```admonish tip
-- Use EUnit for fast unit tests of individual functions
-- Use Common Test for integration tests that need the full application running
-- Run both with `rebar3 do eunit, ct`
-```
-
----
-
-With testing in place, it is time to put everything together and build a complete [CRUD application](../building-app/crud-app.md).
diff --git a/src/data-layer/changesets.md b/src/data-layer/changesets.md
new file mode 100644
index 0000000..f10f60f
--- /dev/null
+++ b/src/data-layer/changesets.md
@@ -0,0 +1,183 @@
+# Changesets and Validation
+
+In the previous chapter we defined schemas and generated migrations. Before we can insert or update data, we need to validate it. Kura uses **changesets** — a data structure that tracks what fields changed, validates them, and accumulates errors. No exceptions, no side effects — just data in, data out.
+
+## The changeset concept
+
+A changeset takes three inputs:
+1. **Data** — the existing record (or `#{}` for a new one)
+2. **Params** — the incoming data (typically from a request body)
+3. **Allowed fields** — which params are permitted (everything else is ignored)
+
+It produces a `#kura_changeset{}` record with:
+- `changes` — a map of field → new value
+- `errors` — a list of `{field, message}` tuples
+- `valid` — `true` or `false`
+
+## Adding changeset functions to schemas
+
+Let's add a `changeset/2` function to the post schema. Update `src/schemas/post.erl`:
+
+```erlang
+-module(post).
+-behaviour(kura_schema).
+-include_lib("kura/include/kura.hrl").
+
+-export([table/0, fields/0, primary_key/0, changeset/2]).
+
+table() -> <<"posts">>.
+
+primary_key() -> id.
+
+fields() ->
+ [
+ #kura_field{name = id, type = id, primary_key = true, nullable = false},
+ #kura_field{name = title, type = string, nullable = false},
+ #kura_field{name = body, type = text},
+ #kura_field{name = status, type = {enum, [draft, published, archived]}, default = <<"draft">>},
+ #kura_field{name = user_id, type = integer},
+ #kura_field{name = inserted_at, type = utc_datetime},
+ #kura_field{name = updated_at, type = utc_datetime}
+ ].
+
+changeset(Data, Params) ->
+ CS = kura_changeset:cast(post, Data, Params, [title, body, status, user_id]),
+ CS1 = kura_changeset:validate_required(CS, [title, body]),
+ CS2 = kura_changeset:validate_length(CS1, title, [{min, 3}, {max, 200}]),
+ kura_changeset:validate_inclusion(CS2, status, [draft, published, archived]).
+```
+
+Here is what each step does:
+
+1. **`cast/4`** — takes the schema module, existing data, incoming params, and a list of allowed fields. It converts param values to the correct Erlang types (binaries to atoms for enums, binaries to integers for IDs, etc.) and puts them in `changes`.
+2. **`validate_required/2`** — ensures the listed fields are present and non-empty.
+3. **`validate_length/3`** — checks string length constraints.
+4. **`validate_inclusion/3`** — ensures the value is one of the allowed options.
+
+## User changeset with format and unique constraints
+
+Update `src/schemas/user.erl`:
+
+```erlang
+-module(user).
+-behaviour(kura_schema).
+-include_lib("kura/include/kura.hrl").
+
+-export([table/0, fields/0, primary_key/0, changeset/2]).
+
+table() -> <<"users">>.
+
+primary_key() -> id.
+
+fields() ->
+ [
+ #kura_field{name = id, type = id, primary_key = true, nullable = false},
+ #kura_field{name = username, type = string, nullable = false},
+ #kura_field{name = email, type = string, nullable = false},
+ #kura_field{name = password_hash, type = string, nullable = false},
+ #kura_field{name = inserted_at, type = utc_datetime},
+ #kura_field{name = updated_at, type = utc_datetime}
+ ].
+
+changeset(Data, Params) ->
+ CS = kura_changeset:cast(user, Data, Params, [username, email, password_hash]),
+ CS1 = kura_changeset:validate_required(CS, [username, email, password_hash]),
+ CS2 = kura_changeset:validate_format(CS1, email, "^[^@]+@[^@]+\\.[^@]+$"),
+ CS3 = kura_changeset:validate_length(CS2, username, [{min, 2}, {max, 50}]),
+ CS4 = kura_changeset:unique_constraint(CS3, email),
+ kura_changeset:unique_constraint(CS4, username).
+```
+
+New validations:
+
+- **`validate_format/3`** — checks the value against a regex. The email regex ensures it has `@` and a domain.
+- **`unique_constraint/2`** — declares that this field has a unique index in the database. If an insert/update violates the constraint, Kura maps the PostgreSQL error to a friendly changeset error instead of crashing.
+
+```admonish info
+`unique_constraint` does not check uniqueness in Erlang — it tells Kura how to handle the PostgreSQL unique violation error. You still need a unique index on the column, which you would add to a migration.
+```
+
+## Changeset errors as structured data
+
+Errors are a list of `{Field, Message}` tuples on the changeset:
+
+```erlang
+1> CS = post:changeset(#{}, #{}).
+#kura_changeset{valid = false, errors = [{title, <<"can't be blank">>},
+ {body, <<"can't be blank">>}], ...}
+
+2> CS#kura_changeset.valid.
+false
+
+3> CS#kura_changeset.errors.
+[{title, <<"can't be blank">>}, {body, <<"can't be blank">>}]
+```
+
+```erlang
+4> CS2 = post:changeset(#{}, #{<<"title">> => <<"Hi">>, <<"body">> => <<"Hello">>}).
+#kura_changeset{valid = false, errors = [{title, <<"must be at least 3 characters">>}], ...}
+```
+
+## Rendering errors in JSON responses
+
+Convert changeset errors to a JSON-friendly map:
+
+```erlang
+changeset_errors_to_json(#kura_changeset{errors = Errors}) ->
+ maps:from_list([{atom_to_binary(Field), Msg} || {Field, Msg} <- Errors]).
+```
+
+Use it in controllers:
+
+```erlang
+create(#{params := Params}) ->
+ CS = post:changeset(#{}, Params),
+ case blog_repo:insert(CS) of
+ {ok, Post} ->
+ {json, 201, #{}, post_to_json(Post)};
+ {error, #kura_changeset{} = CS1} ->
+ {json, 422, #{}, #{errors => changeset_errors_to_json(CS1)}}
+ end.
+```
+
+The response looks like:
+
+```json
+{
+ "errors": {
+ "title": "can't be blank",
+ "body": "can't be blank"
+ }
+}
+```
+
+## Available validation functions
+
+| Function | Purpose |
+|---|---|
+| `validate_required(CS, Fields)` | Fields must be present and non-empty |
+| `validate_format(CS, Field, Regex)` | Value must match the regex |
+| `validate_length(CS, Field, Opts)` | String length: `[{min,N}, {max,N}, {is,N}]` |
+| `validate_number(CS, Field, Opts)` | Number range: `[{greater_than,N}, {less_than,N}]` |
+| `validate_inclusion(CS, Field, List)` | Value must be in the list |
+| `validate_change(CS, Field, Fun)` | Custom validation: `fun(Val) -> ok \| {error, Msg}` |
+| `unique_constraint(CS, Field)` | Map PG unique violation to a changeset error |
+| `foreign_key_constraint(CS, Field)` | Map PG FK violation to a changeset error |
+| `check_constraint(CS, Name, Field, Opts)` | Map PG check constraint to a changeset error |
+
+## Schemaless changesets
+
+For validating data that does not map to a database table (like search filters or contact forms), pass a types map instead of a schema module:
+
+```erlang
+Types = #{query => string, page => integer, per_page => integer},
+CS = kura_changeset:cast(Types, #{}, Params, [query, page, per_page]),
+CS1 = kura_changeset:validate_required(CS, [query]),
+CS2 = kura_changeset:validate_number(CS1, per_page, [{greater_than, 0}, {less_than, 101}]).
+```
+
+Schemaless changesets cannot be persisted via the repo — they are for validation only.
+
+---
+
+Validations are declarative and composable. Errors are data, not exceptions. Now let's use changesets to perform [CRUD operations with the repository](crud.md).
diff --git a/src/data-layer/crud.md b/src/data-layer/crud.md
new file mode 100644
index 0000000..e10308c
--- /dev/null
+++ b/src/data-layer/crud.md
@@ -0,0 +1,259 @@
+# CRUD with the Repository
+
+We have schemas, migrations, and changesets. Now let's use the repository to create, read, update, and delete records — and wire it all up to a controller.
+
+## Insert
+
+Create a record by building a changeset and passing it to `blog_repo:insert/1`:
+
+```erlang
+Params = #{<<"title">> => <<"My First Post">>,
+ <<"body">> => <<"Hello from Nova!">>,
+ <<"status">> => <<"draft">>,
+ <<"user_id">> => 1},
+CS = post:changeset(#{}, Params),
+{ok, Post} = blog_repo:insert(CS).
+```
+
+If the changeset is invalid, `insert` returns `{error, Changeset}` with the errors:
+
+```erlang
+CS = post:changeset(#{}, #{}),
+{error, #kura_changeset{errors = [{title, <<"can't be blank">>}, ...]}} = blog_repo:insert(CS).
+```
+
+## Query all
+
+Use the query builder to fetch records:
+
+```erlang
+Q = kura_query:from(post),
+{ok, Posts} = blog_repo:all(Q).
+```
+
+`Posts` is a list of maps, each representing a row:
+
+```erlang
+[#{id => 1, title => <<"My First Post">>, body => <<"Hello from Nova!">>,
+ status => draft, user_id => 1,
+ inserted_at => {{2026,2,23},{12,0,0}}, updated_at => {{2026,2,23},{12,0,0}}}]
+```
+
+Notice `status` is the atom `draft`, not a binary — Kura handles the conversion.
+
+## Get by ID
+
+Fetch a single record by primary key:
+
+```erlang
+{ok, Post} = blog_repo:get(post, 1).
+{error, not_found} = blog_repo:get(post, 999).
+```
+
+## Update
+
+To update a record, build a changeset from the existing data and new params:
+
+```erlang
+{ok, Post} = blog_repo:get(post, 1),
+CS = post:changeset(Post, #{<<"title">> => <<"Updated Title">>}),
+{ok, UpdatedPost} = blog_repo:update(CS).
+```
+
+Only the changed fields are included in the `UPDATE` statement.
+
+## Delete
+
+Delete takes a changeset built from the existing record:
+
+```erlang
+{ok, Post} = blog_repo:get(post, 1),
+CS = kura_changeset:cast(post, Post, #{}, []),
+{ok, _} = blog_repo:delete(CS).
+```
+
+## Query builder
+
+The query builder composes — chain functions to build up complex queries:
+
+```erlang
+%% Filter by status
+Q = kura_query:from(post),
+Q1 = kura_query:where(Q, {status, <<"published">>}),
+{ok, Published} = blog_repo:all(Q1).
+
+%% Order by insertion date, newest first
+Q2 = kura_query:order_by(Q1, [{inserted_at, desc}]),
+
+%% Limit and offset for pagination
+Q3 = kura_query:limit(Q2, 10),
+Q4 = kura_query:offset(Q3, 20),
+{ok, Page3} = blog_repo:all(Q4).
+```
+
+### Where conditions
+
+```erlang
+%% Equality
+kura_query:where(Q, {title, <<"Hello">>})
+
+%% Comparison operators
+kura_query:where(Q, {user_id, '>', 5})
+kura_query:where(Q, {inserted_at, '>=', {{2026,1,1},{0,0,0}}})
+
+%% IN clause
+kura_query:where(Q, {status, in, [<<"draft">>, <<"published">>]})
+
+%% LIKE / ILIKE
+kura_query:where(Q, {title, ilike, <<"%nova%">>})
+
+%% NULL checks
+kura_query:where(Q, {body, is_nil})
+kura_query:where(Q, {body, is_not_nil})
+
+%% OR conditions
+kura_query:where(Q, {'or', [{status, <<"draft">>}, {status, <<"archived">>}]})
+
+%% AND conditions (multiple where calls are AND by default)
+Q1 = kura_query:where(Q, {status, <<"published">>}),
+Q2 = kura_query:where(Q1, {user_id, 1}).
+```
+
+## Wiring up to a controller
+
+Let's build a posts API controller that uses the repo. Create `src/controllers/blog_posts_controller.erl`:
+
+```erlang
+-module(blog_posts_controller).
+-include_lib("kura/include/kura.hrl").
+
+-export([
+ index/1,
+ show/1,
+ create/1,
+ update/1,
+ delete/1
+ ]).
+
+index(_Req) ->
+ Q = kura_query:from(post),
+ Q1 = kura_query:order_by(Q, [{inserted_at, desc}]),
+ {ok, Posts} = blog_repo:all(Q1),
+ {json, #{posts => [post_to_json(P) || P <- Posts]}}.
+
+show(#{bindings := #{<<"id">> := Id}}) ->
+ case blog_repo:get(post, binary_to_integer(Id)) of
+ {ok, Post} ->
+ {json, post_to_json(Post)};
+ {error, not_found} ->
+ {status, 404, #{}, #{error => <<"post not found">>}}
+ end.
+
+create(#{params := Params}) ->
+ CS = post:changeset(#{}, Params),
+ case blog_repo:insert(CS) of
+ {ok, Post} ->
+ {json, 201, #{}, post_to_json(Post)};
+ {error, #kura_changeset{} = CS1} ->
+ {json, 422, #{}, #{errors => changeset_errors_to_json(CS1)}}
+ end;
+create(_Req) ->
+ {status, 422, #{}, #{error => <<"request body required">>}}.
+
+update(#{bindings := #{<<"id">> := Id}, params := Params}) ->
+ case blog_repo:get(post, binary_to_integer(Id)) of
+ {ok, Post} ->
+ CS = post:changeset(Post, Params),
+ case blog_repo:update(CS) of
+ {ok, Updated} ->
+ {json, post_to_json(Updated)};
+ {error, #kura_changeset{} = CS1} ->
+ {json, 422, #{}, #{errors => changeset_errors_to_json(CS1)}}
+ end;
+ {error, not_found} ->
+ {status, 404, #{}, #{error => <<"post not found">>}}
+ end.
+
+delete(#{bindings := #{<<"id">> := Id}}) ->
+ case blog_repo:get(post, binary_to_integer(Id)) of
+ {ok, Post} ->
+ CS = kura_changeset:cast(post, Post, #{}, []),
+ {ok, _} = blog_repo:delete(CS),
+ {status, 204};
+ {error, not_found} ->
+ {status, 404, #{}, #{error => <<"post not found">>}}
+ end.
+
+%% Helpers
+
+post_to_json(#{id := Id, title := Title, body := Body, status := Status,
+ user_id := UserId, inserted_at := InsertedAt}) ->
+ #{id => Id, title => Title, body => Body,
+ status => atom_to_binary(Status), user_id => UserId,
+ inserted_at => format_datetime(InsertedAt)}.
+
+changeset_errors_to_json(#kura_changeset{errors = Errors}) ->
+ maps:from_list([{atom_to_binary(Field), Msg} || {Field, Msg} <- Errors]).
+
+format_datetime({{Y,Mo,D},{H,Mi,S}}) ->
+ list_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0B",
+ [Y, Mo, D, H, Mi, S]));
+format_datetime(_) ->
+ null.
+```
+
+## Adding the routes
+
+```erlang
+#{prefix => "/api",
+ security => false,
+ routes => [
+ {"/posts", fun blog_posts_controller:index/1, #{methods => [get]}},
+ {"/posts/:id", fun blog_posts_controller:show/1, #{methods => [get]}},
+ {"/posts", fun blog_posts_controller:create/1, #{methods => [post]}},
+ {"/posts/:id", fun blog_posts_controller:update/1, #{methods => [put]}},
+ {"/posts/:id", fun blog_posts_controller:delete/1, #{methods => [delete]}}
+ ]
+}
+```
+
+## Testing with curl
+
+Start the node and test:
+
+```shell
+# Create a post
+curl -s -X POST localhost:8080/api/posts \
+ -H "Content-Type: application/json" \
+ -d '{"title": "My First Post", "body": "Hello from Nova!", "status": "draft", "user_id": 1}' \
+ | python3 -m json.tool
+
+# List all posts
+curl -s localhost:8080/api/posts | python3 -m json.tool
+
+# Get a single post
+curl -s localhost:8080/api/posts/1 | python3 -m json.tool
+
+# Update a post
+curl -s -X PUT localhost:8080/api/posts/1 \
+ -H "Content-Type: application/json" \
+ -d '{"title": "Updated Title", "status": "published"}' \
+ | python3 -m json.tool
+
+# Delete a post
+curl -s -X DELETE localhost:8080/api/posts/1 -w "%{http_code}\n"
+
+# Try creating with invalid data
+curl -s -X POST localhost:8080/api/posts \
+ -H "Content-Type: application/json" \
+ -d '{"title": "Hi"}' \
+ | python3 -m json.tool
+```
+
+The last command returns a 422 with validation errors.
+
+No SQL strings anywhere. The query builder composes, the repo executes.
+
+---
+
+This gives us a working API for a single resource. Next, let's use the [code generators](../building-api/json-api.md) to scaffold resources faster and add JSON schemas for documentation.
diff --git a/src/data-layer/schemas-migrations.md b/src/data-layer/schemas-migrations.md
new file mode 100644
index 0000000..781d4d0
--- /dev/null
+++ b/src/data-layer/schemas-migrations.md
@@ -0,0 +1,225 @@
+# Schemas and Migrations
+
+In the previous chapter we set up the database connection and repo. Now let's define schemas — Erlang modules that describe your data — and watch Kura generate migrations automatically.
+
+## Defining the user schema
+
+Create `src/schemas/user.erl`:
+
+```erlang
+-module(user).
+-behaviour(kura_schema).
+-include_lib("kura/include/kura.hrl").
+
+-export([table/0, fields/0, primary_key/0]).
+
+table() -> <<"users">>.
+
+primary_key() -> id.
+
+fields() ->
+ [
+ #kura_field{name = id, type = id, primary_key = true, nullable = false},
+ #kura_field{name = username, type = string, nullable = false},
+ #kura_field{name = email, type = string, nullable = false},
+ #kura_field{name = password_hash, type = string, nullable = false},
+ #kura_field{name = inserted_at, type = utc_datetime},
+ #kura_field{name = updated_at, type = utc_datetime}
+ ].
+```
+
+A schema module implements the `kura_schema` behaviour and exports three required callbacks:
+
+- **`table/0`** — the PostgreSQL table name
+- **`primary_key/0`** — the primary key field name
+- **`fields/0`** — a list of `#kura_field{}` records describing each column
+
+Each field has a `name` (atom), `type` (one of Kura's types), and optional properties like `nullable` and `default`.
+
+### Kura field types
+
+| Type | PostgreSQL | Erlang |
+|---|---|---|
+| `id` | `BIGSERIAL` | integer |
+| `integer` | `INTEGER` | integer |
+| `float` | `DOUBLE PRECISION` | float |
+| `string` | `VARCHAR(255)` | binary |
+| `text` | `TEXT` | binary |
+| `boolean` | `BOOLEAN` | boolean |
+| `date` | `DATE` | `{Y, M, D}` |
+| `utc_datetime` | `TIMESTAMP` | `{{Y,M,D},{H,Mi,S}}` |
+| `uuid` | `UUID` | binary |
+| `jsonb` | `JSONB` | map/list |
+| `{enum, [atoms]}` | `VARCHAR(255)` | atom |
+| `{array, Type}` | `Type[]` | list |
+
+## Auto-generating migrations
+
+With the `rebar3_kura` compile hook we added in the previous chapter, compile the project:
+
+```shell
+rebar3 compile
+```
+
+```
+===> [kura] Schema diff detected changes
+===> [kura] Generated src/migrations/m20260223120000_create_users.erl
+===> Compiling blog
+```
+
+Kura compared your schema definitions against the current database state (no migrations yet = empty database) and generated a migration file.
+
+## Walking through the migration
+
+Open the generated file in `src/migrations/`:
+
+```erlang
+-module(m20260223120000_create_users).
+-behaviour(kura_migration).
+-include_lib("kura/include/kura.hrl").
+
+-export([up/0, down/0]).
+
+up() ->
+ [{create_table, <<"users">>, [
+ #kura_column{name = id, type = id, primary_key = true, nullable = false},
+ #kura_column{name = username, type = string, nullable = false},
+ #kura_column{name = email, type = string, nullable = false},
+ #kura_column{name = password_hash, type = string, nullable = false},
+ #kura_column{name = inserted_at, type = utc_datetime},
+ #kura_column{name = updated_at, type = utc_datetime}
+ ]}].
+
+down() ->
+ [{drop_table, <<"users">>}].
+```
+
+The migration has two functions:
+- **`up/0`** — returns operations to apply (create the table)
+- **`down/0`** — returns operations to reverse (drop the table)
+
+Migration files are named with a timestamp prefix so they run in order.
+
+## Defining the post schema
+
+Now let's add a post schema with an enum type for status. Create `src/schemas/post.erl`:
+
+```erlang
+-module(post).
+-behaviour(kura_schema).
+-include_lib("kura/include/kura.hrl").
+
+-export([table/0, fields/0, primary_key/0]).
+
+table() -> <<"posts">>.
+
+primary_key() -> id.
+
+fields() ->
+ [
+ #kura_field{name = id, type = id, primary_key = true, nullable = false},
+ #kura_field{name = title, type = string, nullable = false},
+ #kura_field{name = body, type = text},
+ #kura_field{name = status, type = {enum, [draft, published, archived]}, default = <<"draft">>},
+ #kura_field{name = user_id, type = integer},
+ #kura_field{name = inserted_at, type = utc_datetime},
+ #kura_field{name = updated_at, type = utc_datetime}
+ ].
+```
+
+The `status` field uses an enum type — Kura stores it as `VARCHAR(255)` in PostgreSQL but casts between atoms and binaries automatically. When you query a post, `status` comes back as an atom (`draft`, `published`, or `archived`).
+
+Compile again:
+
+```shell
+rebar3 compile
+```
+
+```
+===> [kura] Schema diff detected changes
+===> [kura] Generated src/migrations/m20260223120100_create_posts.erl
+===> Compiling blog
+```
+
+A second migration appears for the posts table.
+
+## Running migrations
+
+Kura runs migrations when the repo starts. On application boot, `blog_repo:start()` checks the `schema_migrations` table and runs any pending migrations in order.
+
+Start the application:
+
+```shell
+rebar3 nova serve
+```
+
+Check the logs — you should see the migrations being applied:
+
+```
+[info] [kura] Running migration: m20260223120000_create_users
+[info] [kura] Running migration: m20260223120100_create_posts
+```
+
+### The schema_migrations table
+
+Kura creates a `schema_migrations` table to track which migrations have been applied:
+
+```sql
+blog_dev=# SELECT * FROM schema_migrations;
+ version | inserted_at
+--------------------+-------------------
+ 20260223120000 | 2026-02-23 12:00:00
+ 20260223120100 | 2026-02-23 12:01:00
+```
+
+Each row records a migration version (the timestamp from the filename). Kura only runs migrations that are not in this table.
+
+## Modifying schemas
+
+When you change a schema — add a field, remove one, or change a type — Kura detects the difference on the next compile and generates an `alter_table` migration.
+
+For example, add a `bio` field to the user schema:
+
+```erlang
+fields() ->
+ [
+ #kura_field{name = id, type = id, primary_key = true, nullable = false},
+ #kura_field{name = username, type = string, nullable = false},
+ #kura_field{name = email, type = string, nullable = false},
+ #kura_field{name = password_hash, type = string, nullable = false},
+ #kura_field{name = bio, type = text},
+ #kura_field{name = inserted_at, type = utc_datetime},
+ #kura_field{name = updated_at, type = utc_datetime}
+ ].
+```
+
+Compile:
+
+```shell
+rebar3 compile
+```
+
+```
+===> [kura] Schema diff detected changes
+===> [kura] Generated src/migrations/m20260223120200_alter_users.erl
+```
+
+The generated migration adds the column:
+
+```erlang
+up() ->
+ [{alter_table, <<"users">>, [
+ {add_column, #kura_column{name = bio, type = text}}
+ ]}].
+
+down() ->
+ [{alter_table, <<"users">>, [
+ {drop_column, bio}
+ ]}].
+```
+
+Define your schema, compile, migration appears. No SQL files to maintain.
+
+---
+
+Now that we have tables, let's learn about [changesets and validation](changesets.md) — how Kura validates and tracks data changes before they hit the database.
diff --git a/src/data-layer/setup.md b/src/data-layer/setup.md
new file mode 100644
index 0000000..a0f2119
--- /dev/null
+++ b/src/data-layer/setup.md
@@ -0,0 +1,197 @@
+# Database Setup
+
+Nova does not include a built-in database layer — by design, you choose what fits your project. We will use [Kura](https://github.com/Taure/kura), an Ecto-inspired database abstraction for Erlang that targets PostgreSQL. Kura gives you schemas, changesets, a query builder, and migrations — no raw SQL required.
+
+## Adding dependencies
+
+Add `kura` and the `rebar3_kura` plugin to `rebar.config`:
+
+```erlang
+{deps, [
+ nova,
+ {flatlog, "0.1.2"},
+ {kura, "~> 1.0"}
+ ]}.
+
+{plugins, [
+ rebar3_nova,
+ {rebar3_kura, "~> 1.0"}
+]}.
+```
+
+Also add `kura` to your application dependencies in `src/blog.app.src`:
+
+```erlang
+{applications,
+ [kernel,
+ stdlib,
+ nova,
+ kura
+ ]},
+```
+
+## Setting up the repository
+
+The `rebar3_kura` plugin provides a setup command that generates a repository module:
+
+```shell
+rebar3 kura setup --name blog_repo
+```
+
+This creates `src/blog_repo.erl` — a module that wraps all database operations:
+
+```erlang
+-module(blog_repo).
+-behaviour(kura_repo).
+
+-export([config/0, start/0, all/1, get/2, get_by/2, one/1,
+ insert/1, insert/2, update/1, delete/1,
+ update_all/2, delete_all/1, insert_all/2,
+ preload/3, transaction/1, multi/1, query/2]).
+
+config() ->
+ #{pool => blog_repo,
+ database => <<"blog_dev">>,
+ hostname => <<"localhost">>,
+ port => 5432,
+ username => <<"postgres">>,
+ password => <<>>,
+ pool_size => 10}.
+
+start() -> kura_repo_worker:start(?MODULE).
+all(Q) -> kura_repo_worker:all(?MODULE, Q).
+get(Schema, Id) -> kura_repo_worker:get(?MODULE, Schema, Id).
+get_by(Schema, Clauses) -> kura_repo_worker:get_by(?MODULE, Schema, Clauses).
+one(Q) -> kura_repo_worker:one(?MODULE, Q).
+insert(CS) -> kura_repo_worker:insert(?MODULE, CS).
+insert(CS, Opts) -> kura_repo_worker:insert(?MODULE, CS, Opts).
+update(CS) -> kura_repo_worker:update(?MODULE, CS).
+delete(CS) -> kura_repo_worker:delete(?MODULE, CS).
+update_all(Q, Updates) -> kura_repo_worker:update_all(?MODULE, Q, Updates).
+delete_all(Q) -> kura_repo_worker:delete_all(?MODULE, Q).
+insert_all(Schema, Entries) -> kura_repo_worker:insert_all(?MODULE, Schema, Entries).
+preload(Schema, Records, Assocs) -> kura_repo_worker:preload(?MODULE, Schema, Records, Assocs).
+transaction(Fun) -> kura_repo_worker:transaction(?MODULE, Fun).
+multi(Multi) -> kura_repo_worker:multi(?MODULE, Multi).
+query(SQL, Params) -> kura_repo_worker:query(?MODULE, SQL, Params).
+```
+
+Every function delegates to `kura_repo_worker` with the repo module as the first argument. The `config/0` callback tells Kura how to connect to PostgreSQL.
+
+The setup command also creates `src/migrations/` for migration files.
+
+## PostgreSQL with Docker Compose
+
+Create `docker-compose.yml` in your project root:
+
+```yaml
+services:
+ db:
+ image: postgres:16
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: blog_dev
+ ports:
+ - "5432:5432"
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+
+volumes:
+ pgdata:
+```
+
+Start it:
+
+```shell
+docker compose up -d
+```
+
+## Configuring the repo
+
+Update `config/dev_sys.config.src` to include the repo config. The repo module reads its config from `config/0`, but you can also configure it through `sys.config` if you prefer environment variable substitution:
+
+```erlang
+[
+ {kernel, [
+ {logger_level, debug},
+ {logger, [
+ {handler, default, logger_std_h,
+ #{formatter => {flatlog, #{
+ map_depth => 3,
+ term_depth => 50,
+ colored => true,
+ template => [colored_start, "[\033[1m", level, "\033[0m",
+ colored_start, "] ", msg, "\n", colored_end]
+ }}}}
+ ]}
+ ]},
+ {nova, [
+ {use_stacktrace, true},
+ {environment, dev},
+ {cowboy_configuration, #{port => 8080}},
+ {dev_mode, true},
+ {bootstrap_application, blog},
+ {plugins, [
+ {pre_request, nova_request_plugin, #{
+ read_urlencoded_body => true,
+ decode_json_body => true
+ }}
+ ]}
+ ]}
+].
+```
+
+## Starting the repo in the supervisor
+
+The repo needs to be started when your application boots. Add it to your supervisor in `src/blog_sup.erl`:
+
+```erlang
+-module(blog_sup).
+-behaviour(supervisor).
+
+-export([start_link/0]).
+-export([init/1]).
+
+start_link() ->
+ supervisor:start_link({local, ?MODULE}, ?MODULE, []).
+
+init([]) ->
+ blog_repo:start(),
+ {ok, {#{strategy => one_for_one, intensity => 5, period => 10}, []}}.
+```
+
+`blog_repo:start()` creates the pgo connection pool using the config from `config/0`.
+
+## Adding the rebar3_kura compile hook
+
+To get automatic migration generation (covered in the next chapter), add a provider hook to `rebar.config`:
+
+```erlang
+{provider_hooks, [
+ {post, [{compile, {kura, compile}}]}
+]}.
+```
+
+This runs `rebar3 kura compile` after every `rebar3 compile`, scanning your schemas and generating migrations for any changes.
+
+## Verifying the connection
+
+Start the development server:
+
+```shell
+rebar3 nova serve
+```
+
+You should see the application start without errors. If the database is unreachable, you will see a connection error in the logs. Verify from the shell:
+
+```erlang
+1> blog_repo:query("SELECT 1", []).
+{ok, #{command => select, num_rows => 1, rows => [{1}]}}
+```
+
+Two commands and you have a database layer.
+
+---
+
+Now let's define our first schemas and watch Kura generate migrations automatically in [Schemas and Migrations](schemas-migrations.md).
diff --git a/src/developer-tools/code-generators.md b/src/developer-tools/code-generators.md
deleted file mode 100644
index 8f5da10..0000000
--- a/src/developer-tools/code-generators.md
+++ /dev/null
@@ -1,162 +0,0 @@
-# Code Generators
-
-The `rebar3_nova` plugin includes generators that scaffold controllers, resources, and test suites. Instead of writing boilerplate by hand, run a single command and get a working starting point.
-
-## Generate a controller
-
-The `nova gen_controller` command creates a controller module with stub action functions:
-
-```shell
-rebar3 nova gen_controller --name products
-===> Writing src/controllers/my_first_nova_products_controller.erl
-```
-
-By default it generates five actions: `list`, `show`, `create`, `update`, and `delete`. Pick specific actions with the `--actions` flag:
-
-```shell
-rebar3 nova gen_controller --name products --actions list,show
-```
-
-The generated controller:
-
-```erlang
--module(my_first_nova_products_controller).
--export([
- list/1,
- show/1,
- create/1,
- update/1,
- delete/1
- ]).
-
-list(_Req) ->
- {json, #{<<"message">> => <<"TODO">>}}.
-
-show(_Req) ->
- {json, #{<<"message">> => <<"TODO">>}}.
-
-create(_Req) ->
- {status, 201, #{}, #{<<"message">> => <<"TODO">>}}.
-
-update(_Req) ->
- {json, #{<<"message">> => <<"TODO">>}}.
-
-delete(_Req) ->
- {status, 204}.
-```
-
-Every action returns a valid Nova response tuple so you can compile and run immediately. Replace the `TODO` values with your actual logic.
-
-## Generate a full resource
-
-The `nova gen_resource` command is the most powerful generator. It creates a controller, a JSON schema, and prints route definitions you can paste into your router:
-
-```shell
-rebar3 nova gen_resource --name products
-===> Writing src/controllers/my_first_nova_products_controller.erl
-===> Writing priv/schemas/product.json
-
-Add these routes to your router:
-
- {<<"/products">>, {my_first_nova_products_controller, list}, #{methods => [get]}}
- {<<"/products/:id">>, {my_first_nova_products_controller, show}, #{methods => [get]}}
- {<<"/products">>, {my_first_nova_products_controller, create}, #{methods => [post]}}
- {<<"/products/:id">>, {my_first_nova_products_controller, update}, #{methods => [put]}}
- {<<"/products/:id">>, {my_first_nova_products_controller, delete}, #{methods => [delete]}}
-```
-
-The generated JSON schema in `priv/schemas/product.json`:
-
-```json
-{
- "$schema": "http://json-schema.org/draft-07/schema#",
- "type": "object",
- "properties": {
- "id": { "type": "integer" },
- "name": { "type": "string" }
- },
- "required": ["id", "name"]
-}
-```
-
-Edit this schema to match your data model. It will be picked up by the [OpenAPI generator](openapi.md) to produce API documentation automatically.
-
-## Generate a test suite
-
-The `nova gen_test` command generates a Common Test suite with test cases for each CRUD action:
-
-```shell
-rebar3 nova gen_test --name products
-===> Writing test/my_first_nova_products_controller_SUITE.erl
-```
-
-The generated suite:
-
-```erlang
--module(my_first_nova_products_controller_SUITE).
--include_lib("common_test/include/ct.hrl").
-
--export([all/0, init_per_suite/1, end_per_suite/1]).
--export([test_list/1, test_show/1, test_create/1, test_update/1, test_delete/1]).
-
-all() ->
- [test_list, test_show, test_create, test_update, test_delete].
-
-init_per_suite(Config) ->
- application:ensure_all_started(my_first_nova),
- Config.
-
-end_per_suite(_Config) ->
- ok.
-
-test_list(_Config) ->
- {ok, {{_, 200, _}, _, _Body}} =
- httpc:request(get, {"http://localhost:8080/products", []}, [], []).
-
-test_show(_Config) ->
- {ok, {{_, 200, _}, _, _Body}} =
- httpc:request(get, {"http://localhost:8080/products/1", []}, [], []).
-
-test_create(_Config) ->
- {ok, {{_, 201, _}, _, _Body}} =
- httpc:request(post, {"http://localhost:8080/products", [],
- "application/json", "{}"}, [], []).
-
-test_update(_Config) ->
- {ok, {{_, 200, _}, _, _Body}} =
- httpc:request(put, {"http://localhost:8080/products/1", [],
- "application/json", "{}"}, [], []).
-
-test_delete(_Config) ->
- {ok, {{_, 204, _}, _, _Body}} =
- httpc:request(delete, {"http://localhost:8080/products/1", []}, [], []).
-```
-
-Update the request bodies and assertions as you flesh out the controller logic.
-
-## Typical workflow
-
-Adding a new resource to your API:
-
-```shell
-# 1. Generate the resource (controller + schema + route hints)
-rebar3 nova gen_resource --name products
-
-# 2. Copy the printed routes into your router
-
-# 3. Edit the JSON schema to match your data model
-
-# 4. Generate a test suite
-rebar3 nova gen_test --name products
-
-# 5. Implement the controller logic
-
-# 6. Run the tests
-rebar3 ct
-```
-
-This saves you from writing boilerplate and gives you a consistent structure across resources.
-
----
-
-Now let's see how the [OpenAPI generator](openapi.md) uses these schemas to produce full API documentation.
diff --git a/src/developer-tools/inspection-tools.md b/src/developer-tools/inspection-tools.md
deleted file mode 100644
index fb09760..0000000
--- a/src/developer-tools/inspection-tools.md
+++ /dev/null
@@ -1,149 +0,0 @@
-# Inspection & Audit Tools
-
-The `rebar3_nova` plugin includes commands for inspecting your application's configuration, middleware chains, and security posture.
-
-## View configuration
-
-The `nova config` command displays all Nova configuration values with their defaults:
-
-```shell
-rebar3 nova config
-=== Nova Configuration ===
-
- bootstrap_application my_first_nova
- environment dev
- cowboy_configuration #{port => 8080}
- plugins [{pre_request,nova_request_plugin,
- #{decode_json_body => true,
- read_urlencoded_body => true}}]
- json_lib thoas (default)
- use_stacktrace true
- dispatch_backend persistent_term (default)
-```
-
-Keys showing `(default)` are using the built-in default rather than an explicit setting.
-
-| Key | Default | Description |
-|-----|---------|-------------|
-| `bootstrap_application` | (required) | Main application to bootstrap |
-| `environment` | `dev` | Current environment |
-| `cowboy_configuration` | `#{port => 8080}` | Cowboy listener settings |
-| `plugins` | `[]` | Global middleware plugins |
-| `json_lib` | `thoas` | JSON encoding library |
-| `use_stacktrace` | `false` | Include stacktraces in error responses |
-| `dispatch_backend` | `persistent_term` | Backend for route dispatch storage |
-
-## Inspect middleware chains
-
-The `nova middleware` command shows the global and per-route-group plugin chains:
-
-```shell
-rebar3 nova middleware
-=== Global Plugins ===
- pre_request: nova_request_plugin #{decode_json_body => true,
- read_urlencoded_body => true}
-
-=== Route Groups (my_first_nova_router) ===
-
- Group: prefix= security=false
- Plugins:
- (inherits global)
- Routes:
- GET /login -> my_first_nova_main_controller:login
- GET /heartbeat -> (inline fun)
- WS /ws -> my_first_nova_ws_handler
-
- Group: prefix=/api security=false
- Plugins:
- (inherits global)
- Routes:
- GET /users -> my_first_nova_api_controller:index
- GET /users/:id -> my_first_nova_api_controller:show
- POST /users -> my_first_nova_api_controller:create
- GET /products -> my_first_nova_products_controller:list
- GET /products/:id -> my_first_nova_products_controller:show
- POST /products -> my_first_nova_products_controller:create
- PUT /products/:id -> my_first_nova_products_controller:update
- DELETE /products/:id -> my_first_nova_products_controller:delete
-```
-
-Each route group shows its prefix, security callback, and which plugins apply. Groups without their own plugins inherit the global list.
-
-## Security audit
-
-The `nova audit` command scans your routes and flags potential security issues:
-
-```shell
-rebar3 nova audit
-=== Security Audit ===
-
- WARNINGS:
- POST /api/users (my_first_nova_api_controller) has no security
- POST /api/products (my_first_nova_products_controller) has no security
- PUT /api/products/:id (my_first_nova_products_controller) has no security
- DELETE /api/products/:id (my_first_nova_products_controller) has no security
-
- INFO:
- GET /login (my_first_nova_main_controller) has no security
- GET /heartbeat has no security
- GET /api/users (my_first_nova_api_controller) has no security
- GET /api/products (my_first_nova_products_controller) has no security
-
- Summary: 4 warning(s), 4 info(s)
-```
-
-The audit classifies findings into two levels:
-
-- **WARNINGS** — mutation methods (POST, PUT, DELETE, PATCH) without security, wildcard method handlers
-- **INFO** — GET routes without security (common for public endpoints but worth reviewing)
-
-```admonish tip
-Run `rebar3 nova audit` before deploying to make sure you haven't left endpoints unprotected by mistake.
-```
-
-To fix the warnings, add a security callback to the route group:
-
-```erlang
-#{prefix => "/api",
- security => fun my_first_nova_auth:validate_token/1,
- routes => [
- {"/products", fun my_first_nova_products_controller:list/1, #{methods => [get]}},
- {"/products/:id", fun my_first_nova_products_controller:show/1, #{methods => [get]}},
- {"/products", fun my_first_nova_products_controller:create/1, #{methods => [post]}},
- {"/products/:id", fun my_first_nova_products_controller:update/1, #{methods => [put]}},
- {"/products/:id", fun my_first_nova_products_controller:delete/1, #{methods => [delete]}}
- ]}
-```
-
-## Listing routes
-
-The `nova routes` command displays the compiled routing tree:
-
-```shell
-rebar3 nova routes
-Host: '_'
- ├─ /api
- │ ├─ GET /users (my_first_nova, my_first_nova_api_controller:index/1)
- │ ├─ GET /users/:id (my_first_nova, my_first_nova_api_controller:show/1)
- │ ├─ POST /users (my_first_nova, my_first_nova_api_controller:create/1)
- │ ├─ GET /products (my_first_nova, my_first_nova_products_controller:list/1)
- │ ├─ GET /products/:id (my_first_nova, my_first_nova_products_controller:show/1)
- │ ├─ POST /products (my_first_nova, my_first_nova_products_controller:create/1)
- │ ├─ PUT /products/:id (my_first_nova, my_first_nova_products_controller:update/1)
- │ └─ DELETE /products/:id (my_first_nova, my_first_nova_products_controller:delete/1)
- ├─ GET /login (my_first_nova, my_first_nova_main_controller:login/1)
- ├─ POST / (my_first_nova, my_first_nova_main_controller:index/1)
- ├─ GET /heartbeat
- └─ WS /ws (my_first_nova, my_first_nova_ws_handler)
-```
-
-## Summary
-
-| Command | Purpose |
-|---------|---------|
-| `rebar3 nova config` | Show Nova configuration with defaults |
-| `rebar3 nova middleware` | Show global and per-group plugin chains |
-| `rebar3 nova audit` | Find routes missing security callbacks |
-| `rebar3 nova routes` | Display the compiled routing tree |
-
-Use `config` to verify settings, `middleware` to trace request processing, `audit` to check security coverage, and `routes` to see the endpoint map.
diff --git a/src/developer-tools/openapi.md b/src/developer-tools/openapi.md
deleted file mode 100644
index 712c13a..0000000
--- a/src/developer-tools/openapi.md
+++ /dev/null
@@ -1,194 +0,0 @@
-# OpenAPI & API Documentation
-
-The `rebar3_nova` plugin can generate an OpenAPI 3.0.3 specification from your routes and JSON schemas. It also produces a Swagger UI page so you can browse and test your API from a browser.
-
-## Prerequisites
-
-For the OpenAPI generator to produce schema definitions, you need JSON schema files in `priv/schemas/`. If you used `nova gen_resource` (see [Code Generators](code-generators.md)) these were created for you. Otherwise create them by hand:
-
-```shell
-mkdir -p priv/schemas
-```
-
-`priv/schemas/user.json`:
-```json
-{
- "$schema": "http://json-schema.org/draft-07/schema#",
- "type": "object",
- "properties": {
- "id": { "type": "integer", "description": "Unique identifier" },
- "name": { "type": "string", "description": "User's full name" },
- "email": { "type": "string", "format": "email", "description": "Email address" }
- },
- "required": ["name", "email"]
-}
-```
-
-`priv/schemas/product.json`:
-```json
-{
- "$schema": "http://json-schema.org/draft-07/schema#",
- "type": "object",
- "properties": {
- "id": { "type": "integer", "description": "Unique identifier" },
- "name": { "type": "string", "description": "Product name" },
- "price": { "type": "number", "description": "Price in cents" }
- },
- "required": ["name", "price"]
-}
-```
-
-## Generating the spec
-
-Run the OpenAPI generator:
-
-```shell
-rebar3 nova openapi
-===> Generated openapi.json
-===> Generated swagger.html
-```
-
-This reads your compiled routes and JSON schemas, then produces two files:
-- `openapi.json` — the OpenAPI 3.0.3 specification
-- `swagger.html` — a standalone Swagger UI page
-
-Customize the output:
-
-```shell
-rebar3 nova openapi \
- --output priv/assets/openapi.json \
- --title "My First Nova API" \
- --api-version 1.0.0
-```
-
-| Flag | Default | Description |
-|------|---------|-------------|
-| `--output` | `openapi.json` | Output file path |
-| `--title` | app name | API title in the spec |
-| `--api-version` | `0.1.0` | API version string |
-
-## What gets generated
-
-The generator inspects every route registered with Nova. For each route it creates a path entry with the correct HTTP method, operation ID, path parameters, and response schema. It skips static file handlers and error controllers.
-
-A snippet from a generated spec:
-
-```json
-{
- "openapi": "3.0.3",
- "info": {
- "title": "My First Nova API",
- "version": "1.0.0"
- },
- "paths": {
- "/api/users": {
- "get": {
- "operationId": "my_first_nova_api_controller.index",
- "responses": {
- "200": {
- "description": "OK",
- "content": {
- "application/json": {
- "schema": { "$ref": "#/components/schemas/user" }
- }
- }
- }
- }
- },
- "post": {
- "operationId": "my_first_nova_api_controller.create",
- "requestBody": {
- "content": {
- "application/json": {
- "schema": { "$ref": "#/components/schemas/user" }
- }
- }
- },
- "responses": {
- "201": { "description": "Created" }
- }
- }
- },
- "/api/products": {
- "get": {
- "operationId": "my_first_nova_products_controller.list",
- "responses": {
- "200": {
- "description": "OK",
- "content": {
- "application/json": {
- "schema": { "$ref": "#/components/schemas/product" }
- }
- }
- }
- }
- }
- }
- },
- "components": {
- "schemas": {
- "user": { "..." : "loaded from priv/schemas/user.json" },
- "product": { "..." : "loaded from priv/schemas/product.json" }
- }
- }
-}
-```
-
-## Swagger UI
-
-The generated `swagger.html` loads the Swagger UI from a CDN and points it at your `openapi.json`. If you place both files in `priv/assets/`, you can serve them through Nova by adding a static route:
-
-```erlang
-{"/docs/[...]", cowboy_static, {priv_dir, my_first_nova, "assets"}}
-```
-
-Then navigate to `http://localhost:8080/docs/swagger.html` to browse your API interactively.
-
-## Auto-generating on release
-
-The `nova release` command automatically regenerates the OpenAPI spec before building a release. If you have a `priv/schemas/` directory, it runs the OpenAPI generator targeting `priv/assets/openapi.json` before calling the standard release build:
-
-```shell
-rebar3 nova release
-===> Generated priv/assets/openapi.json
-===> Generated priv/assets/swagger.html
-===> Release successfully assembled: _build/prod/rel/my_first_nova
-```
-
-You can also specify a release profile:
-
-```shell
-rebar3 nova release --profile staging
-```
-
-This means your deployed application always has up-to-date API documentation bundled in.
-
-## Full workflow
-
-From scratch:
-
-```shell
-# Generate a resource with schema
-rebar3 nova gen_resource --name products
-
-# Edit the schema to match your data model
-vi priv/schemas/product.json
-
-# Implement the controller
-vi src/controllers/my_first_nova_products_controller.erl
-
-# Add routes to your router
-vi src/my_first_nova_router.erl
-
-# Generate the OpenAPI spec
-rebar3 nova openapi --output priv/assets/openapi.json \
- --title "My API" --api-version 1.0.0
-
-# Start the dev server and browse the docs
-rebar3 nova serve
-# Open http://localhost:8080/docs/swagger.html
-```
-
----
-
-Next, let's look at the [inspection and audit tools](inspection-tools.md) that help you understand and verify your application's configuration.
diff --git a/src/getting-started/authentication.md b/src/getting-started/authentication.md
deleted file mode 100644
index cc5f0d4..0000000
--- a/src/getting-started/authentication.md
+++ /dev/null
@@ -1,99 +0,0 @@
-# Authentication
-
-In previous chapters we set up routing, plugins, and a login view. Now let's add authentication so we can handle the login form submission.
-
-## Security in route groups
-
-Authentication in Nova is configured per route group using the `security` key. It points to a function that receives the request and returns either `{true, AuthData}` (allow) or `false` (deny).
-
-## Creating a security module
-
-Create `src/my_first_nova_auth.erl`:
-
-```erlang
--module(my_first_nova_auth).
--export([username_password/1]).
-
-username_password(#{params := Params}) ->
- case Params of
- #{<<"username">> := Username,
- <<"password">> := <<"password">>} ->
- {true, #{authed => true, username => Username}};
- _ ->
- false
- end.
-```
-
-This function checks the decoded form parameters. If the password matches `"password"`, it returns `{true, AuthData}` — the auth data map is attached to the request and accessible in your controller as `auth_data`.
-
-```admonish warning
-This is a hardcoded password for demonstration only. In a real application you would validate credentials against a database with properly hashed passwords.
-```
-
-## Updating the routes
-
-Rearrange the router to separate public and protected routes:
-
-```erlang
-routes(_Environment) ->
- [
- %% Public routes — no security
- #{prefix => "",
- security => false,
- routes => [
- {"/login", fun my_first_nova_main_controller:login/1, #{methods => [get]}},
- {"/heartbeat", fun(_) -> {status, 200} end, #{methods => [get]}}
- ]
- },
-
- %% Protected route — requires authentication
- #{prefix => "",
- security => fun my_first_nova_auth:username_password/1,
- routes => [
- {"/", fun my_first_nova_main_controller:index/1, #{methods => [post]}}
- ]
- }
- ].
-```
-
-The login page is public. The `POST /` route uses `my_first_nova_auth:username_password/1` as its security function. When a POST comes in, Nova calls the security function first — if it returns `false`, the request is rejected with a 401.
-
-## Using auth data in controllers
-
-Update the controller to use the authenticated username:
-
-```erlang
-index(#{auth_data := #{authed := true, username := Username}}) ->
- {ok, [{message, <<"Hello ", Username/binary>>}]};
-index(_Req) ->
- {status, 401}.
-```
-
-Pattern matching on `auth_data` lets you access the data your security function returned. If there is no auth data (someone bypassed security somehow), we return 401.
-
-## Testing the flow
-
-Start the server with `rebar3 nova serve`, then:
-
-1. Go to `http://localhost:8080/login`
-2. Enter any username and `password` as the password
-3. Submit the form
-4. You should see "Hello USERNAME" on the welcome page
-
-If you enter a wrong password, the security function returns `false` and Nova responds with 401.
-
-## How security works
-
-The security flow for each request is:
-
-1. Nova matches the request to a route group
-2. If `security` is `false`, skip to the controller
-3. If `security` is a function, call it with the request map
-4. If it returns `{true, AuthData}`, merge `auth_data => AuthData` into the request and continue to the controller
-5. If it returns `false`, trigger the 401 error handler
-
-You can have different security functions for different route groups — one for API token auth, another for session auth, and so on.
-
----
-
-Our login works, but the authentication is lost on the next request — there is no session. Let's fix that with [sessions](sessions.md).
diff --git a/src/getting-started/create-app.md b/src/getting-started/create-app.md
index 1acfa12..a42a844 100644
--- a/src/getting-started/create-app.md
+++ b/src/getting-started/create-app.md
@@ -17,25 +17,25 @@ This checks for rebar3 (installing it if needed) and adds the `rebar3_nova` plug
Rebar3's `new` command generates project scaffolding. With the Nova plugin installed, you have a `nova` template:
```shell
-rebar3 new nova my_first_nova
+rebar3 new nova blog
```
This creates a directory with everything needed for a running Nova application:
```
-===> Writing my_first_nova/config/dev_sys.config.src
-===> Writing my_first_nova/config/prod_sys.config.src
-===> Writing my_first_nova/src/my_first_nova.app.src
-===> Writing my_first_nova/src/my_first_nova_app.erl
-===> Writing my_first_nova/src/my_first_nova_sup.erl
-===> Writing my_first_nova/src/my_first_nova_router.erl
-===> Writing my_first_nova/src/controllers/my_first_nova_main_controller.erl
-===> Writing my_first_nova/rebar.config
-===> Writing my_first_nova/config/vm.args.src
-===> Writing my_first_nova/priv/assets/favicon.ico
-===> Writing my_first_nova/src/views/my_first_nova_main.dtl
-===> Writing my_first_nova/.tool-versions
-===> Writing my_first_nova/.gitignore
+===> Writing blog/config/dev_sys.config.src
+===> Writing blog/config/prod_sys.config.src
+===> Writing blog/src/blog.app.src
+===> Writing blog/src/blog_app.erl
+===> Writing blog/src/blog_sup.erl
+===> Writing blog/src/blog_router.erl
+===> Writing blog/src/controllers/blog_main_controller.erl
+===> Writing blog/rebar.config
+===> Writing blog/config/vm.args.src
+===> Writing blog/priv/assets/favicon.ico
+===> Writing blog/src/views/blog_main.dtl
+===> Writing blog/.tool-versions
+===> Writing blog/.gitignore
```
```admonish tip
@@ -49,9 +49,9 @@ Here is what was generated:
- **`src/`** — Your source code
- **`src/controllers/`** — Controller modules that handle request logic
- **`src/views/`** — ErlyDTL (Django-style) templates for HTML rendering
- - **`my_first_nova_router.erl`** — Route definitions
- - **`my_first_nova_app.erl`** — OTP application callback
- - **`my_first_nova_sup.erl`** — Supervisor
+ - **`blog_router.erl`** — Route definitions
+ - **`blog_app.erl`** — OTP application callback
+ - **`blog_sup.erl`** — Supervisor
- **`config/`** — Configuration files
- **`dev_sys.config.src`** — Development config (used by `rebar3 shell`)
- **`prod_sys.config.src`** — Production config (used in releases)
@@ -63,7 +63,7 @@ Here is what was generated:
Start the development server:
```shell
-cd my_first_nova
+cd blog
rebar3 nova serve
```
@@ -96,8 +96,8 @@ rebar3 nova routes
```
Host: '_'
├─ /assets
- └─ _ /[...] (my_first_nova, cowboy_static:init/1)
- └─ GET / (my_first_nova, my_first_nova_main_controller:index/1)
+ └─ _ /[...] (blog, cowboy_static:init/1)
+ └─ GET / (blog, blog_main_controller:index/1)
```
This shows the static asset handler and the index route that renders the welcome page.
diff --git a/src/getting-started/plugins.md b/src/getting-started/plugins.md
index 91ea847..2e91665 100644
--- a/src/getting-started/plugins.md
+++ b/src/getting-started/plugins.md
@@ -60,7 +60,7 @@ Each plugin entry is a tuple: `{Phase, Module, Options}` where Phase is `pre_req
## Setting up for our login form
-In the next chapters we will build a login form that sends URL-encoded data. To have Nova decode this automatically, update the plugin config in `dev_sys.config.src`:
+In the next chapter we will build a login form that sends URL-encoded data. To have Nova decode this automatically, update the plugin config in `dev_sys.config.src`:
```erlang
{plugins, [
@@ -71,7 +71,7 @@ In the next chapters we will build a login form that sends URL-encoded data. To
With this setting, form POST data is decoded and placed in the `params` key of the request map, ready for your controller to use.
```admonish tip
-You can enable multiple decoders at once. We will add `decode_json_body => true` later when we build our [JSON API](../building-apis/json-apis.md).
+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).
```
## Built-in plugins
@@ -82,4 +82,4 @@ For now, the key one is `nova_request_plugin` — it handles JSON body decoding,
---
-With plugins configured to decode form data, we can now build our first [view](views.md) — a login page.
+With plugins configured to decode form data, we can now build our first [view and login page](views-auth-sessions.md).
diff --git a/src/getting-started/routing.md b/src/getting-started/routing.md
index c9c0144..c605c4d 100644
--- a/src/getting-started/routing.md
+++ b/src/getting-started/routing.md
@@ -4,10 +4,10 @@ In the previous chapter we created a Nova application and saw it running. Now le
## The router module
-When Nova generated our project, it created `my_first_nova_router.erl`:
+When Nova generated our project, it created `blog_router.erl`:
```erlang
--module(my_first_nova_router).
+-module(blog_router).
-behaviour(nova_router).
-export([
@@ -18,7 +18,7 @@ routes(_Environment) ->
[#{prefix => "",
security => false,
routes => [
- {"/", fun my_first_nova_main_controller:index/1, #{methods => [get]}},
+ {"/", fun blog_main_controller:index/1, #{methods => [get]}},
{"/heartbeat", fun(_) -> {status, 200} end, #{methods => [get]}}
]
}].
@@ -46,14 +46,14 @@ routes(_Environment) ->
[#{prefix => "",
security => false,
routes => [
- {"/", fun my_first_nova_main_controller:index/1, #{methods => [get]}},
+ {"/", fun blog_main_controller:index/1, #{methods => [get]}},
{"/heartbeat", fun(_) -> {status, 200} end, #{methods => [get]}},
- {"/login", fun my_first_nova_main_controller:login/1, #{methods => [get]}}
+ {"/login", fun blog_main_controller:login/1, #{methods => [get]}}
]
}].
```
-We will implement the `login/1` function in the [Views](views.md) chapter.
+We will implement the `login/1` function in the [Views, Auth & Sessions](views-auth-sessions.md) chapter.
## Prefixes for grouping
@@ -63,8 +63,8 @@ The `prefix` key groups related routes under a common path. For example, to buil
#{prefix => "/api/v1",
security => false,
routes => [
- {"/users", fun my_api_controller:list_users/1, #{methods => [get]}},
- {"/users/:id", fun my_api_controller:get_user/1, #{methods => [get]}}
+ {"/users", fun blog_api_controller:list_users/1, #{methods => [get]}},
+ {"/users/:id", fun blog_api_controller:get_user/1, #{methods => [get]}}
]
}
```
@@ -85,7 +85,7 @@ prod_routes() ->
[#{prefix => "",
security => false,
routes => [
- {"/", fun my_first_nova_main_controller:index/1, #{methods => [get]}},
+ {"/", fun blog_main_controller:index/1, #{methods => [get]}},
{"/heartbeat", fun(_) -> {status, 200} end, #{methods => [get]}}
]
}].
@@ -94,7 +94,7 @@ dev_routes() ->
[#{prefix => "",
security => false,
routes => [
- {"/dev-tools", fun my_first_nova_dev_controller:index/1, #{methods => [get]}}
+ {"/dev-tools", fun blog_dev_controller:index/1, #{methods => [get]}}
]
}].
```
diff --git a/src/getting-started/sessions.md b/src/getting-started/sessions.md
deleted file mode 100644
index 316f6f4..0000000
--- a/src/getting-started/sessions.md
+++ /dev/null
@@ -1,230 +0,0 @@
-# Sessions
-
-In the previous chapter we built authentication with a form POST. But if you navigate to another page, the auth is lost — there is no session. Let's fix that.
-
-## How sessions work in Nova
-
-Nova has a built-in session system backed by ETS (Erlang Term Storage). Session IDs are stored in a `session_id` cookie. When a request comes in, you use the session API to get and set values tied to that session.
-
-The session manager is configured in `sys.config`:
-
-```erlang
-{nova, [
- {session_manager, nova_session_ets}
-]}
-```
-
-`nova_session_ets` is the default. It stores session data in an ETS table and replicates changes across clustered nodes using `nova_pubsub`.
-
-## The session API
-
-```erlang
-%% Get a value from the session
-nova_session:get(Req, <<"key">>) -> {ok, Value} | {error, not_found}.
-
-%% Set a value in the session
-nova_session:set(Req, <<"key">>, <<"value">>) -> ok.
-
-%% Delete the entire session (clears the cookie)
-nova_session:delete(Req) -> {ok, Req1}.
-
-%% Delete a specific key from the session
-nova_session:delete(Req, <<"key">>) -> {ok, Req1}.
-
-%% Generate a new session ID
-nova_session:generate_session_id() -> {ok, SessionId}.
-```
-
-All functions take the Cowboy request map to read the `session_id` cookie.
-
-## Adding session-based auth
-
-We need two security functions — one for the login POST (username/password) and one for subsequent requests (session check).
-
-Update `src/my_first_nova_auth.erl`:
-
-```erlang
--module(my_first_nova_auth).
--export([
- username_password/1,
- session_auth/1
- ]).
-
-%% Used for the login POST
-username_password(#{params := Params}) ->
- case Params of
- #{<<"username">> := Username,
- <<"password">> := <<"password">>} ->
- {true, #{authed => true, username => Username}};
- _ ->
- false
- end.
-
-%% Used for pages that need an active session
-session_auth(Req) ->
- case nova_session:get(Req, <<"username">>) of
- {ok, Username} ->
- {true, #{authed => true, username => Username}};
- {error, _} ->
- false
- end.
-```
-
-## Creating the session on login
-
-Update the controller to create a session when authentication succeeds:
-
-```erlang
--module(my_first_nova_main_controller).
--export([
- index/1,
- login/1,
- login_post/1,
- logout/1
- ]).
-
-index(#{auth_data := #{authed := true, username := Username}}) ->
- {ok, [{message, <<"Hello ", Username/binary>>}]};
-index(_Req) ->
- {redirect, "/login"}.
-
-login(_Req) ->
- {ok, [], #{view => login}}.
-
-login_post(#{auth_data := #{authed := true, username := Username}} = Req) ->
- {ok, SessionId} = nova_session:generate_session_id(),
- Req1 = cowboy_req:set_resp_cookie(<<"session_id">>, SessionId, Req,
- #{path => <<"/">>, http_only => true}),
- nova_session_ets:set_value(SessionId, <<"username">>, Username),
- {redirect, "/"};
-login_post(_Req) ->
- {ok, [{error, <<"Invalid username or password">>}], #{view => login}}.
-
-logout(Req) ->
- {ok, _Req1} = nova_session:delete(Req),
- {redirect, "/login"}.
-```
-
-The login flow:
-1. Generate a session ID
-2. Set the `session_id` cookie on the response
-3. Store the username in the session
-4. Redirect to the home page
-
-## Updating the routes
-
-```erlang
-routes(_Environment) ->
- [
- %% Public routes
- #{prefix => "",
- security => false,
- routes => [
- {"/login", fun my_first_nova_main_controller:login/1, #{methods => [get]}},
- {"/heartbeat", fun(_) -> {status, 200} end, #{methods => [get]}}
- ]
- },
-
- %% Login POST (uses username/password auth)
- #{prefix => "",
- security => fun my_first_nova_auth:username_password/1,
- routes => [
- {"/login", fun my_first_nova_main_controller:login_post/1, #{methods => [post]}}
- ]
- },
-
- %% Protected pages (uses session auth)
- #{prefix => "",
- security => fun my_first_nova_auth:session_auth/1,
- routes => [
- {"/", fun my_first_nova_main_controller:index/1, #{methods => [get]}},
- {"/logout", fun my_first_nova_main_controller:logout/1, #{methods => [get]}}
- ]
- }
- ].
-```
-
-Now the flow is:
-1. User visits `/login` → sees the login form
-2. Form POSTs to `/login` → `username_password/1` checks credentials
-3. On success, a session is created and the user is redirected to `/`
-4. On `/`, `session_auth/1` checks the session cookie
-5. `/logout` deletes the session and redirects to `/login`
-
-## Reading session data in controllers
-
-Once a session is active, you can read values from it in any controller:
-
-```erlang
-profile(Req) ->
- case nova_session:get(Req, <<"username">>) of
- {ok, Username} ->
- {json, #{username => Username}};
- {error, _} ->
- {status, 401}
- end.
-```
-
-## Cookie options
-
-When setting the session cookie, control its behaviour with options:
-
-```erlang
-cowboy_req:set_resp_cookie(<<"session_id">>, SessionId, Req, #{
- path => <<"/">>, %% Cookie is valid for all paths
- http_only => true, %% Not accessible from JavaScript
- secure => true, %% Only sent over HTTPS
- max_age => 86400 %% Expires after 24 hours (in seconds)
-}).
-```
-
-```admonish warning
-For production, always set `http_only` and `secure` to `true`.
-```
-
-## Custom session backends
-
-If you want to store sessions in a database or Redis instead of ETS, implement the `nova_session` behaviour:
-
-```erlang
--module(my_redis_session).
--behaviour(nova_session).
-
--export([start_link/0,
- get_value/2,
- set_value/3,
- delete_value/1,
- delete_value/2]).
-
-start_link() ->
- %% Start your Redis connection
- ignore.
-
-get_value(SessionId, Key) ->
- %% Read from Redis
- {ok, Value}.
-
-set_value(SessionId, Key, Value) ->
- %% Write to Redis
- ok.
-
-delete_value(SessionId) ->
- %% Delete entire session from Redis
- ok.
-
-delete_value(SessionId, Key) ->
- %% Delete a single key from Redis
- ok.
-```
-
-Then configure it:
-
-```erlang
-{nova, [
- {session_manager, my_redis_session}
-]}
-```
-
----
-
-We now have a complete authentication and session system. Next, let's move beyond HTML and build a [JSON API](../building-apis/json-apis.md).
diff --git a/src/getting-started/views-auth-sessions.md b/src/getting-started/views-auth-sessions.md
new file mode 100644
index 0000000..7d541de
--- /dev/null
+++ b/src/getting-started/views-auth-sessions.md
@@ -0,0 +1,282 @@
+# Views, Auth & Sessions
+
+In this chapter we will build a login page with ErlyDTL templates, add authentication to protect routes, and wire up sessions so users stay logged in across requests.
+
+## Views with ErlyDTL
+
+Nova uses [ErlyDTL](https://github.com/erlydtl/erlydtl) for HTML templating — an Erlang implementation of [Django's template language](https://django.readthedocs.io/en/1.6.x/ref/templates/builtins.html). Templates live in `src/views/` and are compiled to Erlang modules at build time.
+
+### Creating a login template
+
+Create `src/views/login.dtl`:
+
+```html
+
+
+
+ {% if error %}
{{ error }}
{% endif %}
+
+
+
+
+```
+
+This form POSTs to `/login` with `username` and `password` fields. The URL-encoded body will be decoded by `nova_request_plugin` (which we configured in the [Plugins](plugins.md) chapter).
+
+### Adding a controller function
+
+Our generated controller is in `src/controllers/blog_main_controller.erl`:
+
+```erlang
+-module(blog_main_controller).
+-export([
+ index/1,
+ login/1
+ ]).
+
+index(_Req) ->
+ {ok, [{message, "Hello world!"}]}.
+
+login(_Req) ->
+ {ok, [], #{view => login}}.
+```
+
+The return tuple `{ok, [], #{view => login}}` tells Nova:
+- `ok` — render a template
+- `[]` — no template variables
+- `#{view => login}` — use the `login` template (matches `login.dtl`)
+
+### How template resolution works
+
+When a controller returns `{ok, Variables}` (without a `view` option), Nova looks for a template named after the controller module. For `blog_main_controller:index/1`, it looks for `blog_main.dtl`.
+
+When you specify `#{view => login}`, Nova uses `login.dtl` instead.
+
+## Authentication
+
+Now let's handle the login form submission with a security module.
+
+### Security in route groups
+
+Authentication in Nova is configured per route group using the `security` key. It points to a function that receives the request and returns either `{true, AuthData}` (allow) or `false` (deny).
+
+### Creating a security module
+
+Create `src/blog_auth.erl`:
+
+```erlang
+-module(blog_auth).
+-export([
+ username_password/1,
+ session_auth/1
+ ]).
+
+%% Used for the login POST
+username_password(#{params := Params}) ->
+ case Params of
+ #{<<"username">> := Username,
+ <<"password">> := <<"password">>} ->
+ {true, #{authed => true, username => Username}};
+ _ ->
+ false
+ end.
+
+%% Used for pages that need an active session
+session_auth(Req) ->
+ case nova_session:get(Req, <<"username">>) of
+ {ok, Username} ->
+ {true, #{authed => true, username => Username}};
+ {error, _} ->
+ false
+ end.
+```
+
+`username_password/1` checks the decoded form parameters. If the password matches, it returns `{true, AuthData}` — the auth data map is attached to the request and accessible in your controller as `auth_data`.
+
+`session_auth/1` checks for an existing session (we will set this up next).
+
+```admonish warning
+This is a hardcoded password for demonstration only. In a real application you would validate credentials against a database with properly hashed passwords.
+```
+
+### How security works
+
+The security flow for each request is:
+
+1. Nova matches the request to a route group
+2. If `security` is `false`, skip to the controller
+3. If `security` is a function, call it with the request map
+4. If it returns `{true, AuthData}`, merge `auth_data => AuthData` into the request and continue to the controller
+5. If it returns `false`, trigger the 401 error handler
+
+You can have different security functions for different route groups — one for API token auth, another for session auth, and so on.
+
+## Sessions
+
+Nova has a built-in session system backed by ETS (Erlang Term Storage). Session IDs are stored in a `session_id` cookie.
+
+### The session API
+
+```erlang
+nova_session:get(Req, <<"key">>) -> {ok, Value} | {error, not_found}.
+nova_session:set(Req, <<"key">>, Value) -> ok.
+nova_session:delete(Req) -> {ok, Req1}.
+nova_session:delete(Req, <<"key">>) -> {ok, Req1}.
+nova_session:generate_session_id() -> {ok, SessionId}.
+```
+
+The session manager is configured in `sys.config`:
+
+```erlang
+{nova, [
+ {session_manager, nova_session_ets}
+]}
+```
+
+`nova_session_ets` is the default. It stores session data in an ETS table and replicates changes across clustered nodes using `nova_pubsub`.
+
+### Wiring up the login flow
+
+Update the controller to create a session on successful login:
+
+```erlang
+-module(blog_main_controller).
+-export([
+ index/1,
+ login/1,
+ login_post/1,
+ logout/1
+ ]).
+
+index(#{auth_data := #{authed := true, username := Username}}) ->
+ {ok, [{message, <<"Hello ", Username/binary>>}]};
+index(_Req) ->
+ {redirect, "/login"}.
+
+login(_Req) ->
+ {ok, [], #{view => login}}.
+
+login_post(#{auth_data := #{authed := true, username := Username}} = Req) ->
+ {ok, SessionId} = nova_session:generate_session_id(),
+ Req1 = cowboy_req:set_resp_cookie(<<"session_id">>, SessionId, Req,
+ #{path => <<"/">>, http_only => true}),
+ nova_session_ets:set_value(SessionId, <<"username">>, Username),
+ {redirect, "/"};
+login_post(_Req) ->
+ {ok, [{error, <<"Invalid username or password">>}], #{view => login}}.
+
+logout(Req) ->
+ {ok, _Req1} = nova_session:delete(Req),
+ {redirect, "/login"}.
+```
+
+The login flow:
+1. Generate a session ID
+2. Set the `session_id` cookie on the response
+3. Store the username in the session
+4. Redirect to the home page
+
+### Updating the routes
+
+```erlang
+routes(_Environment) ->
+ [
+ %% Public routes
+ #{prefix => "",
+ security => false,
+ routes => [
+ {"/login", fun blog_main_controller:login/1, #{methods => [get]}},
+ {"/heartbeat", fun(_) -> {status, 200} end, #{methods => [get]}}
+ ]
+ },
+
+ %% Login POST (uses username/password auth)
+ #{prefix => "",
+ security => fun blog_auth:username_password/1,
+ routes => [
+ {"/login", fun blog_main_controller:login_post/1, #{methods => [post]}}
+ ]
+ },
+
+ %% Protected pages (uses session auth)
+ #{prefix => "",
+ security => fun blog_auth:session_auth/1,
+ routes => [
+ {"/", fun blog_main_controller:index/1, #{methods => [get]}},
+ {"/logout", fun blog_main_controller:logout/1, #{methods => [get]}}
+ ]
+ }
+ ].
+```
+
+Now the flow is:
+1. User visits `/login` — sees the login form
+2. Form POSTs to `/login` — `username_password/1` checks credentials
+3. On success, a session is created and the user is redirected to `/`
+4. On `/`, `session_auth/1` checks the session cookie
+5. `/logout` deletes the session and redirects to `/login`
+
+### Cookie options
+
+When setting the session cookie, control its behaviour with options:
+
+```erlang
+cowboy_req:set_resp_cookie(<<"session_id">>, SessionId, Req, #{
+ path => <<"/">>, %% Cookie is valid for all paths
+ http_only => true, %% Not accessible from JavaScript
+ secure => true, %% Only sent over HTTPS
+ max_age => 86400 %% Expires after 24 hours (in seconds)
+}).
+```
+
+```admonish warning
+For production, always set `http_only` and `secure` to `true`.
+```
+
+### Custom session backends
+
+If you want to store sessions in a database or Redis instead of ETS, implement the `nova_session` behaviour:
+
+```erlang
+-module(my_redis_session).
+-behaviour(nova_session).
+
+-export([start_link/0,
+ get_value/2,
+ set_value/3,
+ delete_value/1,
+ delete_value/2]).
+
+start_link() ->
+ ignore.
+
+get_value(SessionId, Key) ->
+ {ok, Value}.
+
+set_value(SessionId, Key, Value) ->
+ ok.
+
+delete_value(SessionId) ->
+ ok.
+
+delete_value(SessionId, Key) ->
+ ok.
+```
+
+Then configure it:
+
+```erlang
+{nova, [
+ {session_manager, my_redis_session}
+]}
+```
+
+---
+
+We now have a complete authentication and session system. Next, let's set up a database layer with [Kura](../data-layer/setup.md).
diff --git a/src/getting-started/views.md b/src/getting-started/views.md
deleted file mode 100644
index 1b21d53..0000000
--- a/src/getting-started/views.md
+++ /dev/null
@@ -1,82 +0,0 @@
-# Views
-
-Nova uses [ErlyDTL](https://github.com/erlydtl/erlydtl) for HTML templating — an Erlang implementation of [Django's template language](https://django.readthedocs.io/en/1.6.x/ref/templates/builtins.html). Templates live in `src/views/` and are compiled to Erlang modules at build time.
-
-## Creating a login template
-
-Let's build a login page. Create `src/views/login.dtl`:
-
-```html
-
-
-
-
-
-
-
-```
-
-This form POSTs to `/login` with `username` and `password` fields. The URL-encoded body will be decoded by `nova_request_plugin` (which we configured in the [Plugins](plugins.md) chapter).
-
-## Adding a controller function
-
-Our generated controller is in `src/controllers/my_first_nova_main_controller.erl`:
-
-```erlang
--module(my_first_nova_main_controller).
--export([
- index/1
- ]).
-
-index(_Req) ->
- {ok, [{message, "Hello world!"}]}.
-```
-
-The `index/1` function returns `{ok, Variables}` — this tells Nova to render the default template for this controller with the given variables. The variable `message` is available as `{{ message }}` in the template.
-
-Let's add a `login/1` function:
-
-```erlang
--module(my_first_nova_main_controller).
--export([
- index/1,
- login/1
- ]).
-
-index(_Req) ->
- {ok, [{message, "Hello world!"}]}.
-
-login(_Req) ->
- {ok, [], #{view => login}}.
-```
-
-The return tuple `{ok, [], #{view => login}}` tells Nova:
-- `ok` — render a template
-- `[]` — no template variables
-- `#{view => login}` — use the `login` template (matches `login.dtl`)
-
-## How template resolution works
-
-When a controller returns `{ok, Variables}` (without a `view` option), Nova looks for a template named after the controller module. For `my_first_nova_main_controller:index/1`, it looks for `my_first_nova_main.dtl`.
-
-When you specify `#{view => login}`, Nova uses `login.dtl` instead.
-
-## Viewing the page
-
-Make sure the `/login` route exists in your router (we added it in the [Routing](routing.md) chapter):
-
-```erlang
-{"/login", fun my_first_nova_main_controller:login/1, #{methods => [get]}}
-```
-
-Start the server with `rebar3 nova serve` and visit `http://localhost:8080/login`. You should see the login form.
-
----
-
-The form submits data but nothing handles it yet. In the next chapter we will add [authentication](authentication.md) to process the login.
diff --git a/src/going-further/cors.md b/src/going-further/cors.md
deleted file mode 100644
index d1b19cc..0000000
--- a/src/going-further/cors.md
+++ /dev/null
@@ -1,148 +0,0 @@
-# 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 that handles this.
-
-## Using nova_cors_plugin
-
-Add it to your plugin configuration:
-
-```erlang
-{plugins, [
- {pre_request, nova_cors_plugin, #{allow_origins => <<"*">>}},
- {pre_request, nova_request_plugin, #{decode_json_body => true}}
-]}
-```
-
-```admonish warning
-Using `<<"*">>` allows requests from any origin. For production, restrict this to your frontend's domain:
-
-~~~erlang
-{pre_request, nova_cors_plugin, #{allow_origins => <<"https://myapp.com">>}}
-~~~
-```
-
-## What the plugin does
-
-1. **Adds CORS headers** to every response:
- - `Access-Control-Allow-Origin` — set to your `allow_origins` value
- - `Access-Control-Allow-Headers` — set to `*`
- - `Access-Control-Allow-Methods` — set to `*`
-
-2. **Handles preflight requests** — when an `OPTIONS` request comes in, the plugin responds with 200 and the CORS headers, then stops the pipeline. The request never reaches your controller.
-
-## Per-route CORS
-
-Apply CORS only to API routes:
-
-```erlang
-routes(_Environment) ->
- [
- %% API routes with CORS
- #{prefix => "/api",
- plugins => [
- {pre_request, nova_cors_plugin, #{allow_origins => <<"https://myapp.com">>}},
- {pre_request, nova_request_plugin, #{decode_json_body => true}}
- ],
- routes => [
- {"/users", fun my_first_nova_api_controller:index/1, #{methods => [get]}},
- {"/users", fun my_first_nova_api_controller:create/1, #{methods => [post]}},
- {"/users/:id", fun my_first_nova_api_controller:show/1, #{methods => [get]}},
- {"/users/:id", fun my_first_nova_api_controller:update/1, #{methods => [put]}},
- {"/users/:id", fun my_first_nova_api_controller:delete/1, #{methods => [delete]}}
- ]
- },
-
- %% HTML routes without CORS
- #{prefix => "",
- plugins => [
- {pre_request, nova_request_plugin, #{read_urlencoded_body => true}}
- ],
- routes => [
- {"/login", fun my_first_nova_main_controller:login/1, #{methods => [get, post]}}
- ]
- }
- ].
-```
-
-## Writing a custom CORS plugin
-
-The built-in plugin hardcodes `Allow-Headers` and `Allow-Methods` to `*`. For more control:
-
-```erlang
--module(my_first_nova_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() ->
- {<<"my_first_nova_cors_plugin">>,
- <<"1.0.0">>,
- <<"My First Nova">>,
- <<"Configurable CORS plugin">>,
- [allow_origins, allow_methods, allow_headers, max_age, allow_credentials]}.
-```
-
-Configure with all options:
-
-```erlang
-{pre_request, my_first_nova_cors_plugin, #{
- allow_origins => <<"https://myapp.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:
-
-```shell
-# Check preflight response
-curl -v -X OPTIONS localhost:8080/api/users \
- -H "Origin: https://myapp.com" \
- -H "Access-Control-Request-Method: POST"
-
-# Check actual response headers
-curl -v localhost:8080/api/users \
- -H "Origin: https://myapp.com"
-```
-
-You should see the `Access-Control-Allow-Origin` header in the response.
-
----
-
-For the final chapter, let's add observability with [OpenTelemetry](opentelemetry.md).
diff --git a/src/going-further/custom-plugins.md b/src/going-further/custom-plugins.md
deleted file mode 100644
index 367b9f5..0000000
--- a/src/going-further/custom-plugins.md
+++ /dev/null
@@ -1,221 +0,0 @@
-# Custom Plugins
-
-In the [Plugins](../getting-started/plugins.md) chapter we saw how Nova's built-in plugins work. Now let's build our own from scratch.
-
-## The nova_plugin behaviour
-
-A plugin module implements these callbacks:
-
-```erlang
--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, Version, Author, Description, Options}.
-```
-
-## Example 1: Request logger
-
-A plugin that logs every request with method, path, and response time.
-
-Create `src/plugins/my_first_nova_logger_plugin.erl`:
-
-```erlang
--module(my_first_nova_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() ->
- {<<"my_first_nova_logger_plugin">>,
- <<"1.0.0">>,
- <<"My First Nova">>,
- <<"Logs request method, path and duration">>,
- []}.
-```
-
-Register it as both pre-request and post-request in `sys.config`:
-
-```erlang
-{plugins, [
- {pre_request, nova_request_plugin, #{decode_json_body => true,
- read_urlencoded_body => true}},
- {pre_request, my_first_nova_logger_plugin, #{}},
- {post_request, my_first_nova_logger_plugin, #{}}
-]}
-```
-
-Output:
-
-```
-[info] GET /api/users completed in 3ms
-[info] POST /api/users completed in 12ms
-```
-
-## Example 2: Rate limiter
-
-A plugin that limits requests per IP address using ETS:
-
-```erlang
--module(my_first_nova_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(nova_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(nova_rate_limits, Key, {2, Count + 1}),
- {ok, Req, State}
- end;
- _ ->
- ets:insert(nova_rate_limits, {Key, 1, Now}),
- {ok, Req, State}
- end.
-
-post_request(Req, _Env, _Options, State) ->
- {ok, Req, State}.
-
-plugin_info() ->
- {<<"my_first_nova_rate_limit_plugin">>,
- <<"1.0.0">>,
- <<"My First Nova">>,
- <<"Simple IP-based rate limiting">>,
- [max_requests, window_ms]}.
-```
-
-Create the ETS table on application start in `src/my_first_nova_app.erl`:
-
-```erlang
-start(_StartType, _StartArgs) ->
- ets:new(nova_rate_limits, [named_table, public, set]),
- my_first_nova_sup:start_link().
-```
-
-Configure:
-
-```erlang
-{pre_request, my_first_nova_rate_limit_plugin, #{
- max_requests => 60,
- window_ms => 60000
-}}
-```
-
-When the limit is exceeded, the plugin returns `{stop, Reply, State}` — a 429 response is sent and the controller is never called.
-
-## Example 3: Request ID plugin
-
-Add a unique request ID header to every response:
-
-```erlang
--module(my_first_nova_request_id_plugin).
--behaviour(nova_plugin).
-
--export([pre_request/4,
- post_request/4,
- plugin_info/0]).
-
-pre_request(Req, _Env, _Options, State) ->
- RequestId = generate_id(),
- Req1 = cowboy_req:set_resp_header(<<"x-request-id">>, RequestId, Req),
- {ok, Req1#{request_id => RequestId}, State}.
-
-post_request(Req, _Env, _Options, State) ->
- {ok, Req, State}.
-
-plugin_info() ->
- {<<"my_first_nova_request_id_plugin">>,
- <<"1.0.0">>,
- <<"My First Nova">>,
- <<"Adds X-Request-ID header to responses">>,
- []}.
-
-generate_id() ->
- Bytes = crypto:strong_rand_bytes(16),
- list_to_binary(
- lists:flatten(
- [io_lib:format("~2.16.0b", [B]) || <> <= Bytes])).
-```
-
-## Per-route plugins
-
-Set plugins on specific route groups instead of globally:
-
-```erlang
-routes(_Environment) ->
- [
- #{prefix => "/api",
- plugins => [
- {pre_request, nova_cors_plugin, #{allow_origins => <<"*">>}},
- {pre_request, nova_request_plugin, #{decode_json_body => true}},
- {pre_request, my_first_nova_rate_limit_plugin, #{max_requests => 100}}
- ],
- routes => [
- {"/users", fun my_first_nova_api_controller:index/1, #{methods => [get]}}
- ]
- },
-
- #{prefix => "",
- plugins => [
- {pre_request, nova_request_plugin, #{read_urlencoded_body => true}}
- ],
- routes => [
- {"/login", fun my_first_nova_main_controller:login/1, #{methods => [get, post]}}
- ]
- }
- ].
-```
-
-When `plugins` is set on a route group, it overrides the global plugin configuration for those routes.
-
-## Plugin return values
-
-| Return | Effect |
-|---|---|
-| `{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 |
-
----
-
-Next, let's look at [CORS](cors.md) — a common need when your API serves a separate frontend.
diff --git a/src/going-further/openapi-tools.md b/src/going-further/openapi-tools.md
new file mode 100644
index 0000000..089003f
--- /dev/null
+++ b/src/going-further/openapi-tools.md
@@ -0,0 +1,267 @@
+# OpenAPI, Inspection & Audit
+
+The `rebar3_nova` plugin includes tools for generating API documentation, inspecting your application's configuration, and auditing security. This chapter covers all three.
+
+## OpenAPI documentation
+
+### Prerequisites
+
+For the OpenAPI generator to produce schema definitions, you need JSON schema files in `priv/schemas/`. If you used `nova gen_resource` (see [JSON API with Generators](../building-api/json-api.md)) these were created for you. Otherwise create them by hand:
+
+```shell
+mkdir -p priv/schemas
+```
+
+`priv/schemas/post.json`:
+```json
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "properties": {
+ "id": { "type": "integer", "description": "Unique identifier" },
+ "title": { "type": "string", "description": "Post title" },
+ "body": { "type": "string", "description": "Post body" },
+ "status": { "type": "string", "enum": ["draft", "published", "archived"] }
+ },
+ "required": ["title", "body"]
+}
+```
+
+### Generating the spec
+
+Run the OpenAPI generator:
+
+```shell
+rebar3 nova openapi
+===> Generated openapi.json
+===> Generated swagger.html
+```
+
+This reads your compiled routes and JSON schemas, then produces two files:
+- `openapi.json` — the OpenAPI 3.0.3 specification
+- `swagger.html` — a standalone Swagger UI page
+
+Customize the output:
+
+```shell
+rebar3 nova openapi \
+ --output priv/assets/openapi.json \
+ --title "Blog API" \
+ --api-version 1.0.0
+```
+
+| Flag | Default | Description |
+|------|---------|-------------|
+| `--output` | `openapi.json` | Output file path |
+| `--title` | app name | API title in the spec |
+| `--api-version` | `0.1.0` | API version string |
+
+### What gets generated
+
+The generator inspects every route registered with Nova. For each route it creates a path entry with the correct HTTP method, operation ID, path parameters, and response schema. It skips static file handlers and error controllers.
+
+A snippet from a generated spec:
+
+```json
+{
+ "openapi": "3.0.3",
+ "info": {
+ "title": "Blog API",
+ "version": "1.0.0"
+ },
+ "paths": {
+ "/api/posts": {
+ "get": {
+ "operationId": "blog_posts_controller.index",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/post" }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "operationId": "blog_posts_controller.create",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/post" }
+ }
+ }
+ },
+ "responses": {
+ "201": { "description": "Created" }
+ }
+ }
+ }
+ }
+}
+```
+
+### Swagger UI
+
+The generated `swagger.html` loads the Swagger UI from a CDN and points it at your `openapi.json`. If you place both files in `priv/assets/`, you can serve them through Nova by adding a static route:
+
+```erlang
+{"/docs/[...]", cowboy_static, {priv_dir, blog, "assets"}}
+```
+
+Then navigate to `http://localhost:8080/docs/swagger.html` to browse your API interactively.
+
+### Auto-generating on release
+
+The `nova release` command automatically regenerates the OpenAPI spec before building a release:
+
+```shell
+rebar3 nova release
+===> Generated priv/assets/openapi.json
+===> Generated priv/assets/swagger.html
+===> Release successfully assembled: _build/prod/rel/blog
+```
+
+This means your deployed application always has up-to-date API documentation bundled in.
+
+## Inspection tools
+
+### View configuration
+
+The `nova config` command displays all Nova configuration values with their defaults:
+
+```shell
+rebar3 nova config
+=== Nova Configuration ===
+
+ bootstrap_application blog
+ environment dev
+ cowboy_configuration #{port => 8080}
+ plugins [{pre_request,nova_request_plugin,
+ #{decode_json_body => true,
+ read_urlencoded_body => true}}]
+ json_lib thoas (default)
+ use_stacktrace true
+ dispatch_backend persistent_term (default)
+```
+
+Keys showing `(default)` are using the built-in default rather than an explicit setting.
+
+| Key | Default | Description |
+|-----|---------|-------------|
+| `bootstrap_application` | (required) | Main application to bootstrap |
+| `environment` | `dev` | Current environment |
+| `cowboy_configuration` | `#{port => 8080}` | Cowboy listener settings |
+| `plugins` | `[]` | Global middleware plugins |
+| `json_lib` | `thoas` | JSON encoding library |
+| `use_stacktrace` | `false` | Include stacktraces in error responses |
+| `dispatch_backend` | `persistent_term` | Backend for route dispatch storage |
+
+### Inspect middleware chains
+
+The `nova middleware` command shows the global and per-route-group plugin chains:
+
+```shell
+rebar3 nova middleware
+=== Global Plugins ===
+ pre_request: nova_request_plugin #{decode_json_body => true,
+ read_urlencoded_body => true}
+
+=== Route Groups (blog_router) ===
+
+ Group: prefix= security=false
+ Plugins:
+ (inherits global)
+ Routes:
+ GET /login -> blog_main_controller:login
+ GET /heartbeat -> (inline fun)
+
+ Group: prefix=/api security=false
+ Plugins:
+ (inherits global)
+ Routes:
+ GET /posts -> blog_posts_controller:index
+ POST /posts -> blog_posts_controller:create
+ GET /posts/:id -> blog_posts_controller:show
+ PUT /posts/:id -> blog_posts_controller:update
+ DELETE /posts/:id -> blog_posts_controller:delete
+```
+
+### Listing routes
+
+The `nova routes` command displays the compiled routing tree:
+
+```shell
+rebar3 nova routes
+Host: '_'
+ ├─ /api
+ │ ├─ GET /posts (blog, blog_posts_controller:index/1)
+ │ ├─ GET /posts/:id (blog, blog_posts_controller:show/1)
+ │ ├─ POST /posts (blog, blog_posts_controller:create/1)
+ │ ├─ PUT /posts/:id (blog, blog_posts_controller:update/1)
+ │ └─ DELETE /posts/:id (blog, blog_posts_controller:delete/1)
+ ├─ GET /login (blog, blog_main_controller:login/1)
+ └─ GET /heartbeat
+```
+
+## Security audit
+
+The `nova audit` command scans your routes and flags potential security issues:
+
+```shell
+rebar3 nova audit
+=== Security Audit ===
+
+ WARNINGS:
+ POST /api/posts (blog_posts_controller) has no security
+ PUT /api/posts/:id (blog_posts_controller) has no security
+ DELETE /api/posts/:id (blog_posts_controller) has no security
+
+ INFO:
+ GET /login (blog_main_controller) has no security
+ GET /heartbeat has no security
+ GET /api/posts (blog_posts_controller) has no security
+
+ Summary: 3 warning(s), 3 info(s)
+```
+
+The audit classifies findings into two levels:
+
+- **WARNINGS** — mutation methods (POST, PUT, DELETE, PATCH) without security, wildcard method handlers
+- **INFO** — GET routes without security (common for public endpoints but worth reviewing)
+
+```admonish tip
+Run `rebar3 nova audit` before deploying to make sure you haven't left endpoints unprotected by mistake.
+```
+
+To fix the warnings, add a security callback to the route group:
+
+```erlang
+#{prefix => "/api",
+ security => fun blog_auth:validate_token/1,
+ routes => [
+ {"/posts", fun blog_posts_controller:index/1, #{methods => [get]}},
+ {"/posts/:id", fun blog_posts_controller:show/1, #{methods => [get]}},
+ {"/posts", fun blog_posts_controller:create/1, #{methods => [post]}},
+ {"/posts/:id", fun blog_posts_controller:update/1, #{methods => [put]}},
+ {"/posts/:id", fun blog_posts_controller:delete/1, #{methods => [delete]}}
+ ]}
+```
+
+## Command summary
+
+| Command | Purpose |
+|---------|---------|
+| `rebar3 nova openapi` | Generate OpenAPI 3.0.3 spec + Swagger UI |
+| `rebar3 nova config` | Show Nova configuration with defaults |
+| `rebar3 nova middleware` | Show global and per-group plugin chains |
+| `rebar3 nova audit` | Find routes missing security callbacks |
+| `rebar3 nova routes` | Display the compiled routing tree |
+| `rebar3 nova release` | Build release with auto-generated OpenAPI |
+
+Use `config` to verify settings, `middleware` to trace request processing, `audit` to check security coverage, and `routes` to see the endpoint map.
+
+---
+
+Next, let's learn how to write [custom plugins and handle CORS](plugins-cors.md).
diff --git a/src/going-further/opentelemetry.md b/src/going-further/opentelemetry.md
index 873af7c..1ac1b8e 100644
--- a/src/going-further/opentelemetry.md
+++ b/src/going-further/opentelemetry.md
@@ -24,6 +24,7 @@ Add `opentelemetry_nova` and the OpenTelemetry SDK to `rebar.config`:
```erlang
{deps, [
nova,
+ {kura, "~> 1.0"},
{opentelemetry, "~> 1.5"},
{opentelemetry_experimental, "~> 0.5"},
{opentelemetry_exporter, "~> 1.8"},
@@ -90,7 +91,7 @@ In your application's `start/2`, initialize metrics and start the Prometheus HTT
```erlang
start(_StartType, _StartArgs) ->
opentelemetry_nova:setup(#{prometheus => #{port => 9464}}),
- my_app_sup:start_link().
+ blog_sup:start_link().
```
This starts a Prometheus endpoint at `http://localhost:9464/metrics`. Point your Prometheus server or Grafana Agent at it.
@@ -108,13 +109,29 @@ routes(_Environment) ->
[#{
plugins => [{pre_request, otel_nova_plugin, #{}}],
routes => [
- {"/hello", fun my_controller:hello/1, #{methods => [get]}},
- {"/users", fun my_controller:users/1, #{methods => [get, post]}}
+ {"/posts", fun blog_posts_controller:index/1, #{methods => [get]}},
+ {"/posts/:id", fun blog_posts_controller:show/1, #{methods => [get]}}
]
}].
```
-Spans get enriched with `nova.app`, `nova.controller`, and `nova.action` attributes, and the span name becomes `GET my_controller:hello` instead of just `HTTP GET`.
+Spans get enriched with `nova.app`, `nova.controller`, and `nova.action` attributes, and the span name becomes `GET blog_posts_controller:index` instead of just `HTTP GET`.
+
+## Kura query telemetry
+
+Kura has its own telemetry for database queries. Enable it in `sys.config`:
+
+```erlang
+{kura, [{log, true}]}
+```
+
+This logs every query with its SQL, parameters, duration, and row count. For custom handling, pass an `{M, F}` tuple:
+
+```erlang
+{kura, [{log, {blog_telemetry, log_query}}]}
+```
+
+Combined with OpenTelemetry HTTP spans, you get end-to-end visibility from the HTTP request through the database query and back.
## Full sys.config example
@@ -127,6 +144,8 @@ Spans get enriched with `nova.app`, `nova.controller`, and `nova.action` attribu
}}
]},
+ {kura, [{log, true}]},
+
{opentelemetry, [
{span_processor, batch},
{traces_exporter, {opentelemetry_exporter, #{
@@ -157,8 +176,9 @@ Spans get enriched with `nova.app`, `nova.controller`, and `nova.action` attribu
Make some requests:
```shell
-curl http://localhost:8080/hello
-curl -X POST -d '{"name":"nova"}' http://localhost:8080/users
+curl http://localhost:8080/api/posts
+curl -X POST -H "Content-Type: application/json" \
+ -d '{"title":"Test","body":"Hello"}' http://localhost:8080/api/posts
```
Check the Prometheus endpoint:
diff --git a/src/going-further/plugins-cors.md b/src/going-further/plugins-cors.md
new file mode 100644
index 0000000..7d97cf3
--- /dev/null
+++ b/src/going-further/plugins-cors.md
@@ -0,0 +1,287 @@
+# Custom Plugins and CORS
+
+In the [Plugins](../getting-started/plugins.md) 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
+
+A plugin module implements these callbacks:
+
+```erlang
+-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, Version, Author, Description, Options}.
+```
+
+## Example: Request logger
+
+A plugin that logs every request with method, path, and response time.
+
+Create `src/plugins/blog_logger_plugin.erl`:
+
+```erlang
+-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`:
+
+```erlang
+{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:
+
+```erlang
+-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`:
+
+```erlang
+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:
+
+```erlang
+{plugins, [
+ {pre_request, nova_cors_plugin, #{allow_origins => <<"*">>}},
+ {pre_request, nova_request_plugin, #{decode_json_body => true}}
+]}
+```
+
+```admonish warning
+Using `<<"*">>` allows requests from any origin. For production, restrict this to your frontend's domain:
+
+~~~erlang
+{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:
+
+```erlang
+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:
+
+```erlang
+-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:
+
+```erlang
+{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:
+
+```shell
+# 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
+
+| Return | Effect |
+|---|---|
+| `{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](opentelemetry.md).
diff --git a/src/going-further/pubsub.md b/src/going-further/pubsub.md
deleted file mode 100644
index 5e6d39c..0000000
--- a/src/going-further/pubsub.md
+++ /dev/null
@@ -1,191 +0,0 @@
-# Pub/Sub
-
-In the [WebSockets](../building-apis/websockets.md) chapter we briefly used `nova_pubsub` to broadcast chat messages. Now let's dive deeper into Nova's pub/sub system and build real-time notifications for our notes application.
-
-## How nova_pubsub works
-
-Nova's pub/sub is built on OTP's `pg` module (process groups). It starts automatically with Nova — no configuration needed. Any Erlang process can join channels, and messages are delivered to all members.
-
-```erlang
-%% Join a channel
-nova_pubsub:join(channel_name).
-
-%% Leave a channel
-nova_pubsub:leave(channel_name).
-
-%% Broadcast to all members on all nodes
-nova_pubsub:broadcast(channel_name, Topic, Payload).
-
-%% Broadcast to members on the local node only
-nova_pubsub:local_broadcast(channel_name, Topic, Payload).
-
-%% Get all members of a channel
-nova_pubsub:get_members(channel_name).
-
-%% Get members on the local node
-nova_pubsub:get_local_members(channel_name).
-```
-
-Channels are atoms. Topics can be lists or binaries. Payloads can be anything.
-
-## Message format
-
-When a process receives a pub/sub message, it arrives as:
-
-```erlang
-{nova_pubsub, Channel, SenderPid, Topic, Payload}
-```
-
-In a gen_server, handle this in `handle_info/2`. In a WebSocket handler, use `websocket_info/2`.
-
-## Building real-time notifications
-
-Let's notify all connected clients when notes are created, updated, or deleted.
-
-### Notification WebSocket handler
-
-Create `src/controllers/my_first_nova_notifications_handler.erl`:
-
-```erlang
--module(my_first_nova_notifications_handler).
--behaviour(nova_websocket).
-
--export([
- init/1,
- websocket_handle/2,
- websocket_info/2
- ]).
-
-init(State) ->
- nova_pubsub:join(notes),
- {ok, State}.
-
-websocket_handle({text, <<"ping">>}, State) ->
- {reply, {text, <<"pong">>}, State};
-websocket_handle(_Frame, State) ->
- {ok, State}.
-
-websocket_info({nova_pubsub, notes, _Sender, Topic, Payload}, State) ->
- Msg = thoas:encode(#{
- event => list_to_binary(Topic),
- data => Payload
- }),
- {reply, {text, Msg}, State};
-websocket_info(_Info, State) ->
- {ok, State}.
-```
-
-On connect, the handler joins the `notes` channel. Pub/sub messages are encoded as JSON and forwarded to the client.
-
-### Broadcasting from controllers
-
-Update the notes API controller to broadcast on changes:
-
-```erlang
-create(#{params := #{<<"title">> := Title, <<"body">> := Body,
- <<"author">> := Author}}) ->
- case my_first_nova_note_repo:create(Title, Body, Author) of
- {ok, Note} ->
- nova_pubsub:broadcast(notes, "note_created", Note),
- {json, 201, #{}, Note};
- {error, Reason} ->
- {status, 422, #{}, #{error => list_to_binary(io_lib:format("~p", [Reason]))}}
- end;
-```
-
-Add `nova_pubsub:broadcast/3` after each successful create, update, and delete.
-
-### Adding the route
-
-```erlang
-{"/notifications", my_first_nova_notifications_handler, #{protocol => ws}}
-```
-
-### Client-side JavaScript
-
-```javascript
-const ws = new WebSocket("ws://localhost:8080/notifications");
-
-ws.onmessage = (event) => {
- const msg = JSON.parse(event.data);
- console.log(`Event: ${msg.event}`, msg.data);
-
- switch (msg.event) {
- case "note_created":
- // Add the new note to the UI
- break;
- case "note_updated":
- // Update the note in the UI
- break;
- case "note_deleted":
- // Remove the note from the UI
- break;
- }
-};
-```
-
-## Using pub/sub in gen_servers
-
-Any Erlang process can join a channel. This is useful for background workers:
-
-```erlang
--module(my_first_nova_note_indexer).
--behaviour(gen_server).
-
--export([start_link/0]).
--export([init/1, handle_info/2, handle_cast/2, handle_call/3]).
-
-start_link() ->
- gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
-
-init([]) ->
- nova_pubsub:join(notes),
- {ok, #{}}.
-
-handle_info({nova_pubsub, notes, _Sender, "note_created", Note}, State) ->
- logger:info("Indexing new note: ~p", [maps:get(title, Note)]),
- {noreply, State};
-handle_info({nova_pubsub, notes, _Sender, "note_deleted", #{id := Id}}, State) ->
- logger:info("Removing note ~p from index", [Id]),
- {noreply, State};
-handle_info(_Info, State) ->
- {noreply, State}.
-
-handle_cast(_Msg, State) ->
- {noreply, State}.
-
-handle_call(_Req, _From, State) ->
- {reply, ok, State}.
-```
-
-## Distributed pub/sub
-
-`nova_pubsub` works across Erlang nodes. If you have multiple instances connected in a cluster, `broadcast/3` delivers to all members on all nodes.
-
-For local-only messaging:
-
-```erlang
-nova_pubsub:local_broadcast(notes, "note_created", Note).
-```
-
-Useful for node-specific effects like clearing a local cache.
-
-## Organizing channels and topics
-
-```erlang
-%% Different channels for different domains
-nova_pubsub:join(notes).
-nova_pubsub:join(users).
-nova_pubsub:join(system).
-
-%% Topics within channels for filtering
-nova_pubsub:broadcast(notes, "created", Note).
-nova_pubsub:broadcast(users, "logged_in", #{username => User}).
-nova_pubsub:broadcast(system, "deploy", #{version => <<"1.2.0">>}).
-```
-
-Processes can join multiple channels and pattern match on channel and topic in their handlers.
-
----
-
-Next, let's learn how to write [custom plugins](custom-plugins.md) for the request pipeline.
diff --git a/src/introduction.md b/src/introduction.md
index 26dc2b3..64f40cd 100644
--- a/src/introduction.md
+++ b/src/introduction.md
@@ -4,8 +4,6 @@
[Nova](https://github.com/novaframework/nova) is a web framework for Erlang/OTP. It handles routing, request processing, template rendering, sessions, and WebSockets — the core pieces you need to build web applications and APIs. Nova sits on top of [Cowboy](https://github.com/ninenines/cowboy), the battle-tested Erlang HTTP server, and adds a structured layer for organizing your application.
-Nova was born from the frustration of repetitive setup tasks when building web services with Cowboy directly. It provides quick bootstrapping, eliminates boilerplate, and gives you clear visibility into your application's request flow.
-
## Who this book is for
This book is for experienced web developers who want to build web applications with Erlang. You should be comfortable with at least one other web framework (Express, Rails, Django, Phoenix, etc.) and understand HTTP, REST, and basic database concepts.
@@ -14,24 +12,24 @@ You do not need prior Erlang experience. The [Erlang Essentials](appendix/erlang
## What you'll build
-Throughout this book you will build a notes application step by step:
+Throughout this book you will build a **blog platform** step by step:
1. **A Nova application from scratch** — project structure, routing, and your first controller
2. **An HTML frontend** — login page, views with ErlyDTL templates, authentication and sessions
-3. **A JSON API** — RESTful endpoints returning JSON, with path parameters and request body parsing
-4. **Database persistence** — PostgreSQL integration with connection pooling
-5. **Real-time features** — WebSockets and pub/sub for live notifications
-6. **Developer tooling** — code generators, OpenAPI documentation, and security audits
-7. **Production deployment** — OTP releases, Docker, and systemd
+3. **A database layer with Kura** — schemas, migrations, changesets, and a repository for PostgreSQL
+4. **A JSON API** — RESTful endpoints with code generators, associations, preloading, and embedded schemas
+5. **Real-time features** — WebSockets and pub/sub for a live comment feed
+6. **Production concerns** — transactions, bulk operations, error handling, and deployment
+7. **Developer tooling** — OpenAPI documentation, security audits, custom plugins, and OpenTelemetry
-By the end you will have a complete, deployable application and a solid understanding of how Nova works.
+The blog has users who write posts, readers who leave comments, and tags for organizing content. This naturally exercises Kura's key features: schemas with associations, enum types (post status), embedded schemas (post metadata as JSONB), changesets with validation, many-to-many relationships (posts and tags), transactions, and bulk operations.
```admonish info title="Prerequisites"
Before starting, make sure you have:
-- **Erlang/OTP 26+** — install via [mise](https://mise.jdx.dev/) (recommended), [asdf](https://asdf-vm.com/), or your system package manager
+- **Erlang/OTP 27+** — install via [mise](https://mise.jdx.dev/) (recommended), [asdf](https://asdf-vm.com/), or your system package manager
- **Rebar3** — the Erlang build tool, also installable via mise/asdf
-- **PostgreSQL** — for the database chapters (12+ recommended)
+- **Docker** — for running PostgreSQL (we use Docker Compose throughout)
- A text editor and a terminal
See the [Erlang Essentials](appendix/erlang-essentials.md) appendix for detailed setup instructions.
@@ -39,7 +37,7 @@ See the [Erlang Essentials](appendix/erlang-essentials.md) appendix for detailed
## How to read this book
-The chapters are designed to be read in order. Each one builds on the previous — the application grows progressively from a bare project to a full-featured, deployed service. Code examples accumulate, so what you build in Chapter 2 is extended in Chapter 5 and deployed in Chapter 12.
+The chapters are designed to be read in order. Each one builds on the previous — the application grows progressively from a bare project to a full-featured, deployed service. Code examples accumulate, so what you build in Chapter 2 is extended in Chapter 6 and deployed in Chapter 17.
If you are already familiar with Nova, you can jump to specific chapters. The [Cheat Sheet](appendix/cheat-sheet.md) appendix is a useful standalone reference.
diff --git a/src/building-app/deployment.md b/src/production/deployment.md
similarity index 73%
rename from src/building-app/deployment.md
rename to src/production/deployment.md
index 6e32816..4c3d649 100644
--- a/src/building-app/deployment.md
+++ b/src/production/deployment.md
@@ -7,8 +7,8 @@ In development we use `rebar3 nova serve` with hot-reloading and debug logging.
Rebar3 uses `relx` to build releases. The generated `rebar.config` includes a release configuration:
```erlang
-{relx, [{release, {my_first_nova, "0.1.0"},
- [my_first_nova,
+{relx, [{release, {blog, "0.1.0"},
+ [blog,
sasl]},
{dev_mode, true},
{include_erts, false},
@@ -65,7 +65,7 @@ Key differences:
{environment, prod},
{cowboy_configuration, #{port => 8080}},
{dev_mode, false},
- {bootstrap_application, my_first_nova},
+ {bootstrap_application, blog},
{plugins, [
{pre_request, nova_request_plugin, #{
decode_json_body => true,
@@ -73,9 +73,9 @@ Key differences:
}}
]}
]},
- {my_first_nova, [
- {db, #{
- host => "${DB_HOST}",
+ {blog, [
+ {repo, #{
+ hostname => "${DB_HOST}",
port => 5432,
database => "${DB_NAME}",
username => "${DB_USER}",
@@ -96,7 +96,7 @@ Key differences:
`config/vm.args.src` controls Erlang VM settings. For production:
```
--name my_first_nova@${HOSTNAME}
+-name blog@${HOSTNAME}
-setcookie ${RELEASE_COOKIE}
+K true
+A30
@@ -121,34 +121,34 @@ If you have JSON schemas in `priv/schemas/`, you can use `nova release` instead.
rebar3 nova release
===> Generated priv/assets/openapi.json
===> Generated priv/assets/swagger.html
-===> Release successfully assembled: _build/prod/rel/my_first_nova
+===> Release successfully assembled: _build/prod/rel/blog
```
-This ensures your deployed application always ships with up-to-date API documentation. See [OpenAPI & API Documentation](../developer-tools/openapi.md) for details.
+This ensures your deployed application always ships with up-to-date API documentation. See [OpenAPI, Inspection & Audit](../going-further/openapi-tools.md) for details.
Start it:
```shell
-_build/prod/rel/my_first_nova/bin/my_first_nova foreground
+_build/prod/rel/blog/bin/blog foreground
```
Or as a daemon:
```shell
-_build/prod/rel/my_first_nova/bin/my_first_nova daemon
+_build/prod/rel/blog/bin/blog daemon
```
Other commands:
```shell
# Check if the node is running
-_build/prod/rel/my_first_nova/bin/my_first_nova ping
+_build/prod/rel/blog/bin/blog ping
# Attach a remote shell
-_build/prod/rel/my_first_nova/bin/my_first_nova remote_console
+_build/prod/rel/blog/bin/blog remote_console
# Stop the node
-_build/prod/rel/my_first_nova/bin/my_first_nova stop
+_build/prod/rel/blog/bin/blog stop
```
## Building a tarball
@@ -159,13 +159,13 @@ For deployment to another machine:
rebar3 as prod tar
```
-This creates `my_first_nova-0.1.0.tar.gz`. Since ERTS is included, the target server does not need Erlang installed:
+This creates `blog-0.1.0.tar.gz`. Since ERTS is included, the target server does not need Erlang installed:
```shell
# On the server
-mkdir -p /opt/my_first_nova
-tar -xzf my_first_nova-0.1.0.tar.gz -C /opt/my_first_nova
-/opt/my_first_nova/bin/my_first_nova daemon
+mkdir -p /opt/blog
+tar -xzf blog-0.1.0.tar.gz -C /opt/blog
+/opt/blog/bin/blog daemon
```
## SSL/TLS
@@ -178,8 +178,8 @@ Configure HTTPS in Nova:
use_ssl => true,
ssl_port => 8443,
ssl_options => #{
- certfile => "/etc/letsencrypt/live/myapp.com/fullchain.pem",
- keyfile => "/etc/letsencrypt/live/myapp.com/privkey.pem"
+ certfile => "/etc/letsencrypt/live/myblog.com/fullchain.pem",
+ keyfile => "/etc/letsencrypt/live/myblog.com/privkey.pem"
}
}}
]}
@@ -193,21 +193,21 @@ Run as a system service:
```ini
[Unit]
-Description=My First Nova Application
+Description=Blog Application
After=network.target postgresql.service
[Service]
Type=forking
-User=nova
-Group=nova
-WorkingDirectory=/opt/my_first_nova
-ExecStart=/opt/my_first_nova/bin/my_first_nova daemon
-ExecStop=/opt/my_first_nova/bin/my_first_nova stop
+User=blog
+Group=blog
+WorkingDirectory=/opt/blog
+ExecStart=/opt/blog/bin/blog daemon
+ExecStop=/opt/blog/bin/blog stop
Restart=on-failure
RestartSec=5
Environment=DB_HOST=localhost
-Environment=DB_NAME=my_first_nova_prod
-Environment=DB_USER=nova
+Environment=DB_NAME=blog_prod
+Environment=DB_USER=blog
Environment=DB_PASSWORD=secret
Environment=RELEASE_COOKIE=my_secret_cookie
@@ -217,8 +217,8 @@ WantedBy=multi-user.target
```shell
sudo systemctl daemon-reload
-sudo systemctl enable my_first_nova
-sudo systemctl start my_first_nova
+sudo systemctl enable blog
+sudo systemctl start blog
```
## Docker
@@ -226,7 +226,7 @@ sudo systemctl start my_first_nova
A multi-stage Dockerfile:
```dockerfile
-FROM erlang:26 AS builder
+FROM erlang:27 AS builder
WORKDIR /app
COPY . .
@@ -238,24 +238,28 @@ FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y libssl3 libncurses6 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
-COPY --from=builder /app/_build/prod/rel/my_first_nova/*.tar.gz .
+COPY --from=builder /app/_build/prod/rel/blog/*.tar.gz .
RUN tar -xzf *.tar.gz && rm *.tar.gz
EXPOSE 8080
-CMD ["/app/bin/my_first_nova", "foreground"]
+CMD ["/app/bin/blog", "foreground"]
```
Build and run:
```shell
-docker build -t my_first_nova .
+docker build -t blog .
docker run -p 8080:8080 \
-e DB_HOST=host.docker.internal \
- -e DB_NAME=my_first_nova_prod \
- -e DB_USER=nova \
+ -e DB_NAME=blog_prod \
+ -e DB_USER=blog \
-e DB_PASSWORD=secret \
- my_first_nova
+ blog
+```
+
+```admonish tip
+For sub-applications like Nova Admin, add them to your release deps and `nova_apps` config. They are bundled automatically in the release. See [Custom Plugins and CORS](../going-further/plugins-cors.md) for plugin configuration that carries over to production.
```
## Summary
@@ -271,4 +275,4 @@ OTP releases are self-contained — once built, everything you need is in a sing
---
-Our application is deployed. Now let's explore more advanced features, starting with [pub/sub](../going-further/pubsub.md).
+Now let's explore more advanced features, starting with [OpenAPI, Inspection & Audit](../going-further/openapi-tools.md).
diff --git a/src/production/pubsub.md b/src/production/pubsub.md
new file mode 100644
index 0000000..89b57b6
--- /dev/null
+++ b/src/production/pubsub.md
@@ -0,0 +1,256 @@
+# Pub/Sub and Real-Time Feed
+
+In the [WebSockets](websockets.md) chapter we used `nova_pubsub` to broadcast comments. Now let's dive deeper into Nova's pub/sub system and build a real-time feed for our blog — live notifications when posts are published and comments are added.
+
+## How nova_pubsub works
+
+Nova's pub/sub is built on OTP's `pg` module (process groups). It starts automatically with Nova — no configuration needed. Any Erlang process can join channels, and messages are delivered to all members.
+
+```erlang
+%% Join a channel
+nova_pubsub:join(channel_name).
+
+%% Leave a channel
+nova_pubsub:leave(channel_name).
+
+%% Broadcast to all members on all nodes
+nova_pubsub:broadcast(channel_name, Topic, Payload).
+
+%% Broadcast to members on the local node only
+nova_pubsub:local_broadcast(channel_name, Topic, Payload).
+
+%% Get all members of a channel
+nova_pubsub:get_members(channel_name).
+
+%% Get members on the local node
+nova_pubsub:get_local_members(channel_name).
+```
+
+Channels are atoms. Topics can be lists or binaries. Payloads can be anything.
+
+## Message format
+
+When a process receives a pub/sub message, it arrives as:
+
+```erlang
+{nova_pubsub, Channel, SenderPid, Topic, Payload}
+```
+
+In a gen_server, handle this in `handle_info/2`. In a WebSocket handler, use `websocket_info/2`.
+
+## Building the real-time feed
+
+### Notification WebSocket handler
+
+Create `src/controllers/blog_feed_handler.erl`:
+
+```erlang
+-module(blog_feed_handler).
+-behaviour(nova_websocket).
+
+-export([
+ init/1,
+ websocket_handle/2,
+ websocket_info/2
+ ]).
+
+init(State) ->
+ nova_pubsub:join(posts),
+ nova_pubsub:join(comments),
+ {ok, State}.
+
+websocket_handle({text, <<"ping">>}, State) ->
+ {reply, {text, <<"pong">>}, State};
+websocket_handle(_Frame, State) ->
+ {ok, State}.
+
+websocket_info({nova_pubsub, Channel, _Sender, Topic, Payload}, State) ->
+ Msg = thoas:encode(#{
+ channel => Channel,
+ event => list_to_binary(Topic),
+ data => Payload
+ }),
+ {reply, {text, Msg}, State};
+websocket_info(_Info, State) ->
+ {ok, State}.
+```
+
+On connect, the handler joins both the `posts` and `comments` channels. Any pub/sub message is encoded as JSON and forwarded to the client.
+
+### Broadcasting from controllers
+
+Update the posts controller to broadcast on changes:
+
+```erlang
+create(#{params := Params}) ->
+ CS = post:changeset(#{}, Params),
+ case blog_repo:insert(CS) of
+ {ok, Post} ->
+ nova_pubsub:broadcast(posts, "post_created", post_to_json(Post)),
+ {json, 201, #{}, post_to_json(Post)};
+ {error, #kura_changeset{} = CS1} ->
+ {json, 422, #{}, #{errors => changeset_errors_to_json(CS1)}}
+ end;
+```
+
+Do the same for updates and deletes:
+
+```erlang
+%% After a successful update:
+nova_pubsub:broadcast(posts, "post_updated", post_to_json(Updated)),
+
+%% After a successful delete:
+nova_pubsub:broadcast(posts, "post_deleted", #{id => binary_to_integer(Id)}),
+```
+
+And for comments:
+
+```erlang
+%% After creating a comment:
+nova_pubsub:broadcast(comments, "comment_created", comment_to_json(Comment)),
+```
+
+### Adding the route
+
+```erlang
+{"/feed", blog_feed_handler, #{protocol => ws}}
+```
+
+### Client-side JavaScript
+
+```javascript
+const ws = new WebSocket("ws://localhost:8080/feed");
+
+ws.onmessage = (event) => {
+ const msg = JSON.parse(event.data);
+ console.log(`[${msg.channel}] ${msg.event}:`, msg.data);
+
+ switch (msg.event) {
+ case "post_created":
+ // Add the new post to the feed
+ break;
+ case "post_updated":
+ // Update the post in the feed
+ break;
+ case "post_deleted":
+ // Remove the post from the feed
+ break;
+ case "comment_created":
+ // Append the new comment
+ break;
+ }
+};
+
+// Keep-alive
+setInterval(() => ws.send("ping"), 30000);
+```
+
+## Per-post comment feeds
+
+For a live comment section on a specific post, use dynamic channel names:
+
+```erlang
+-module(blog_post_comments_handler).
+-behaviour(nova_websocket).
+
+-export([init/1, websocket_handle/2, websocket_info/2]).
+
+init(#{bindings := #{<<"post_id">> := PostId}} = State) ->
+ Channel = list_to_atom("post_comments_" ++ binary_to_list(PostId)),
+ nova_pubsub:join(Channel),
+ {ok, State#{channel => Channel}};
+init(State) ->
+ {ok, State}.
+
+websocket_handle(_Frame, State) ->
+ {ok, State}.
+
+websocket_info({nova_pubsub, _Channel, _Sender, _Topic, Payload}, State) ->
+ {reply, {text, thoas:encode(Payload)}, State};
+websocket_info(_Info, State) ->
+ {ok, State}.
+```
+
+Route:
+
+```erlang
+{"/posts/:post_id/comments/ws", blog_post_comments_handler, #{protocol => ws}}
+```
+
+When creating a comment, broadcast to the post-specific channel:
+
+```erlang
+Channel = list_to_atom("post_comments_" ++ integer_to_list(PostId)),
+nova_pubsub:broadcast(Channel, "new_comment", comment_to_json(Comment)).
+```
+
+## Using pub/sub in gen_servers
+
+Any Erlang process can join a channel. This is useful for background workers like search indexing:
+
+```erlang
+-module(blog_search_indexer).
+-behaviour(gen_server).
+
+-export([start_link/0]).
+-export([init/1, handle_info/2, handle_cast/2, handle_call/3]).
+
+start_link() ->
+ gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
+
+init([]) ->
+ nova_pubsub:join(posts),
+ {ok, #{}}.
+
+handle_info({nova_pubsub, posts, _Sender, "post_created", Post}, State) ->
+ logger:info("Indexing new post: ~p", [maps:get(title, Post)]),
+ %% Add to search index
+ {noreply, State};
+handle_info({nova_pubsub, posts, _Sender, "post_deleted", #{id := Id}}, State) ->
+ logger:info("Removing post ~p from index", [Id]),
+ %% Remove from search index
+ {noreply, State};
+handle_info(_Info, State) ->
+ {noreply, State}.
+
+handle_cast(_Msg, State) ->
+ {noreply, State}.
+
+handle_call(_Req, _From, State) ->
+ {reply, ok, State}.
+```
+
+Add it to your supervisor to start automatically.
+
+## Distributed pub/sub
+
+`nova_pubsub` works across Erlang nodes. If you have multiple instances connected in a cluster, `broadcast/3` delivers to all members on all nodes.
+
+For local-only messaging (e.g., clearing a local cache):
+
+```erlang
+nova_pubsub:local_broadcast(posts, "cache_invalidated", #{id => PostId}).
+```
+
+## Organizing channels and topics
+
+```erlang
+%% Different channels for different domains
+nova_pubsub:join(posts).
+nova_pubsub:join(comments).
+nova_pubsub:join(users).
+nova_pubsub:join(system).
+
+%% Topics within channels for filtering
+nova_pubsub:broadcast(posts, "created", Post).
+nova_pubsub:broadcast(posts, "published", Post).
+nova_pubsub:broadcast(comments, "created", Comment).
+nova_pubsub:broadcast(users, "logged_in", #{username => User}).
+nova_pubsub:broadcast(system, "deploy", #{version => <<"1.2.0">>}).
+```
+
+Processes can join multiple channels and pattern match on channel and topic in their handlers.
+
+---
+
+Next, let's look at [transactions, multi, and bulk operations](transactions-bulk.md) for atomic and efficient data operations.
diff --git a/src/production/transactions-bulk.md b/src/production/transactions-bulk.md
new file mode 100644
index 0000000..00851e6
--- /dev/null
+++ b/src/production/transactions-bulk.md
@@ -0,0 +1,205 @@
+# Transactions, Multi & Bulk Operations
+
+For simple CRUD, the repo functions are enough. But some operations need atomicity (all-or-nothing), multi-step pipelines, or bulk efficiency. Kura provides transactions, multi, and bulk operations for these cases.
+
+## Transactions
+
+Wrap multiple operations in a transaction — if any step fails, everything rolls back:
+
+```erlang
+blog_repo:transaction(fun() ->
+ CS1 = user:changeset(#{}, #{<<"username">> => <<"alice">>,
+ <<"email">> => <<"alice@example.com">>,
+ <<"password_hash">> => <<"hashed">>}),
+ {ok, User} = blog_repo:insert(CS1),
+
+ CS2 = post:changeset(#{}, #{<<"title">> => <<"Welcome">>,
+ <<"body">> => <<"Hello world">>,
+ <<"user_id">> => maps:get(id, User)}),
+ {ok, _Post} = blog_repo:insert(CS2),
+ ok
+end).
+```
+
+If the second insert fails, the user creation is rolled back too. The transaction function returns `{ok, ReturnValue}` on success or `{error, Reason}` on failure.
+
+## Multi: named transaction pipelines
+
+For complex multi-step operations, `kura_multi` provides a pipeline where each step has a name and can reference results from previous steps:
+
+```erlang
+M = kura_multi:new(),
+
+%% Step 1: Create a user
+M1 = kura_multi:insert(M, create_user,
+ user:changeset(#{}, #{<<"username">> => <<"alice">>,
+ <<"email">> => <<"alice@example.com">>,
+ <<"password_hash">> => <<"hashed">>})),
+
+%% Step 2: Create a first draft, using the user ID from step 1
+M2 = kura_multi:insert(M1, create_draft,
+ fun(#{create_user := User}) ->
+ post:changeset(#{}, #{<<"title">> => <<"My First Draft">>,
+ <<"body">> => <<"Coming soon...">>,
+ <<"user_id">> => maps:get(id, User)})
+ end),
+
+%% Step 3: Run a custom function
+M3 = kura_multi:run(M2, send_welcome,
+ fun(#{create_user := User}) ->
+ logger:info("Welcome ~s!", [maps:get(username, User)]),
+ {ok, sent}
+ end),
+
+%% Execute everything atomically
+case blog_repo:multi(M3) of
+ {ok, #{create_user := User, create_draft := Post, send_welcome := sent}} ->
+ logger:info("User ~p created with draft post ~p",
+ [maps:get(id, User), maps:get(id, Post)]);
+ {error, FailedStep, FailedValue, _Completed} ->
+ logger:error("Multi failed at step ~p: ~p", [FailedStep, FailedValue])
+end.
+```
+
+### Multi API
+
+| Function | Purpose |
+|---|---|
+| `kura_multi:new()` | Create a new multi |
+| `kura_multi:insert(M, Name, CS)` | Insert a record (changeset or fun returning changeset) |
+| `kura_multi:update(M, Name, CS)` | Update a record |
+| `kura_multi:delete(M, Name, CS)` | Delete a record |
+| `kura_multi:run(M, Name, Fun)` | Run a custom function |
+
+Steps that take a fun receive a map of all completed steps so far:
+
+```erlang
+fun(#{step1 := Result1, step2 := Result2}) -> ...
+```
+
+### Error handling
+
+When a multi fails, you get the name of the failed step, the error value, and a map of steps that completed before the failure:
+
+```erlang
+case blog_repo:multi(M) of
+ {ok, Results} ->
+ %% All steps succeeded, Results is a map of step_name => result
+ ok;
+ {error, FailedStep, FailedValue, CompletedSteps} ->
+ %% FailedStep: atom name of the step that failed
+ %% FailedValue: the error (e.g., a changeset with errors)
+ %% CompletedSteps: map of steps that succeeded (then rolled back)
+ ok
+end.
+```
+
+## Bulk operations
+
+### insert_all — batch inserts
+
+Insert many records at once:
+
+```erlang
+Posts = [
+ #{title => <<"Post 1">>, body => <<"Body 1">>, status => <<"draft">>, user_id => 1},
+ #{title => <<"Post 2">>, body => <<"Body 2">>, status => <<"draft">>, user_id => 1},
+ #{title => <<"Post 3">>, body => <<"Body 3">>, status => <<"published">>, user_id => 2}
+],
+{ok, 3} = blog_repo:insert_all(post, Posts).
+```
+
+`insert_all` bypasses changesets — it inserts raw maps directly. Use it for imports and seeding where you trust the data. The return value is the number of rows inserted.
+
+### update_all — batch updates
+
+Update many records matching a query:
+
+```erlang
+%% Publish all drafts
+Q = kura_query:from(post),
+Q1 = kura_query:where(Q, {status, <<"draft">>}),
+{ok, Count} = blog_repo:update_all(Q1, #{status => <<"published">>}).
+```
+
+`update_all` returns the count of rows affected. It applies the updates in a single SQL statement.
+
+### delete_all — batch deletes
+
+Delete all records matching a query:
+
+```erlang
+%% Delete all archived posts
+Q = kura_query:from(post),
+Q1 = kura_query:where(Q, {status, <<"archived">>}),
+{ok, Count} = blog_repo:delete_all(Q1).
+```
+
+## Upserts with on_conflict
+
+Import data without failing on duplicates:
+
+```erlang
+%% Insert a tag, do nothing if it already exists
+CS = tag:changeset(#{}, #{<<"name">> => <<"erlang">>}),
+{ok, Tag} = blog_repo:insert(CS, #{on_conflict => {name, nothing}}).
+```
+
+The `on_conflict` option controls what happens when a unique constraint is violated:
+
+```erlang
+%% Do nothing on conflict (skip the row)
+#{on_conflict => {name, nothing}}
+
+%% Replace all fields on conflict
+#{on_conflict => {name, replace_all}}
+
+%% Replace specific fields on conflict
+#{on_conflict => {name, {replace, [updated_at]}}}
+
+%% Use a named constraint instead of a field
+#{on_conflict => {{constraint, <<"tags_name_key">>}, nothing}}
+```
+
+### Practical example: importing posts
+
+```erlang
+import_posts(Posts) ->
+ lists:foreach(fun(PostData) ->
+ CS = post:changeset(#{}, PostData),
+ blog_repo:insert(CS, #{on_conflict => {title, nothing}})
+ end, Posts).
+```
+
+## Putting it all together
+
+A controller action that publishes a post and notifies subscribers atomically:
+
+```erlang
+publish(#{bindings := #{<<"id">> := Id}}) ->
+ case blog_repo:get(post, binary_to_integer(Id)) of
+ {ok, #{status := draft} = Post} ->
+ M = kura_multi:new(),
+ M1 = kura_multi:update(M, publish_post,
+ post:changeset(Post, #{<<"status">> => <<"published">>})),
+ M2 = kura_multi:run(M1, notify,
+ fun(#{publish_post := Published}) ->
+ nova_pubsub:broadcast(posts, "post_published", Published),
+ {ok, notified}
+ end),
+ case blog_repo:multi(M2) of
+ {ok, #{publish_post := Published}} ->
+ {json, post_to_json(Published)};
+ {error, _Step, _Value, _} ->
+ {status, 422, #{}, #{error => <<"failed to publish">>}}
+ end;
+ {ok, _} ->
+ {status, 422, #{}, #{error => <<"only drafts can be published">>}};
+ {error, not_found} ->
+ {status, 404, #{}, #{error => <<"post not found">>}}
+ end.
+```
+
+---
+
+With transactions and bulk operations covered, let's prepare the application for [deployment](deployment.md).
diff --git a/src/building-apis/websockets.md b/src/production/websockets.md
similarity index 75%
rename from src/building-apis/websockets.md
rename to src/production/websockets.md
index 4a41df5..24f60ef 100644
--- a/src/building-apis/websockets.md
+++ b/src/production/websockets.md
@@ -1,15 +1,15 @@
# WebSockets
-HTTP request-response works well for most operations, but sometimes you need real-time, bidirectional communication. Nova has built-in WebSocket support through the `nova_websocket` behaviour.
+HTTP request-response works well for most operations, but sometimes you need real-time, bidirectional communication. Nova has built-in WebSocket support through the `nova_websocket` behaviour. We will use it to build a live comments handler for our blog.
## Creating a WebSocket handler
A WebSocket handler implements three callbacks: `init/1`, `websocket_handle/2`, and `websocket_info/2`.
-Create `src/controllers/my_first_nova_ws_handler.erl`:
+Create `src/controllers/blog_ws_handler.erl`:
```erlang
--module(my_first_nova_ws_handler).
+-module(blog_ws_handler).
-behaviour(nova_websocket).
-export([
@@ -41,7 +41,7 @@ The callbacks:
WebSocket routes use the module name as an atom (not a fun reference) and set `protocol => ws`:
```erlang
-{"/ws", my_first_nova_ws_handler, #{protocol => ws}}
+{"/ws", blog_ws_handler, #{protocol => ws}}
```
Add it to your public routes:
@@ -50,9 +50,9 @@ Add it to your public routes:
#{prefix => "",
security => false,
routes => [
- {"/login", fun my_first_nova_main_controller:login/1, #{methods => [get]}},
+ {"/login", fun blog_main_controller:login/1, #{methods => [get]}},
{"/heartbeat", fun(_) -> {status, 200} end, #{methods => [get]}},
- {"/ws", my_first_nova_ws_handler, #{protocol => ws}}
+ {"/ws", blog_ws_handler, #{protocol => ws}}
]
}
```
@@ -68,14 +68,14 @@ ws.onopen = () => ws.send("Hello Nova!");
// Should log: "Echo: Hello Nova!"
```
-## A chat handler
+## A live comments handler
-Let's build something more practical — a chat handler that broadcasts messages to all connected clients using `nova_pubsub`.
+Let's build something more practical — a handler that broadcasts new comments to all connected clients using `nova_pubsub`.
-Create `src/controllers/my_first_nova_chat_handler.erl`:
+Create `src/controllers/blog_comments_ws_handler.erl`:
```erlang
--module(my_first_nova_chat_handler).
+-module(blog_comments_ws_handler).
-behaviour(nova_websocket).
-export([
@@ -85,22 +85,22 @@ Create `src/controllers/my_first_nova_chat_handler.erl`:
]).
init(State) ->
- nova_pubsub:join(chat),
+ nova_pubsub:join(comments),
{ok, State}.
websocket_handle({text, Msg}, State) ->
- nova_pubsub:broadcast(chat, "message", Msg),
+ nova_pubsub:broadcast(comments, "new_comment", Msg),
{ok, State};
websocket_handle(_Frame, State) ->
{ok, State}.
-websocket_info({nova_pubsub, chat, _Sender, "message", Msg}, State) ->
+websocket_info({nova_pubsub, comments, _Sender, "new_comment", Msg}, State) ->
{reply, {text, Msg}, State};
websocket_info(_Info, State) ->
{ok, State}.
```
-In `init/1` we join the `chat` channel. When a client sends a message, we broadcast it to all channel members. When a pub/sub message arrives via `websocket_info/2`, we forward it to the connected client. We will explore pub/sub in depth in the [Pub/Sub](../going-further/pubsub.md) chapter.
+In `init/1` we join the `comments` channel. When a client sends a message, we broadcast it to all channel members. When a pub/sub message arrives via `websocket_info/2`, we forward it to the connected client. We will explore pub/sub in depth in the [Pub/Sub](pubsub.md) chapter.
## Custom handlers
@@ -155,4 +155,4 @@ resolve(Req, InvalidReturn) ->
---
-We have covered HTML, JSON, and WebSocket responses. Now let's persist data with [database integration](../data-and-testing/database-integration.md).
+With WebSockets in place, let's build a real-time comment feed using [Pub/Sub](pubsub.md).
diff --git a/src/building-app/error-handling.md b/src/testing-errors/error-handling.md
similarity index 73%
rename from src/building-app/error-handling.md
rename to src/testing-errors/error-handling.md
index 0f218f4..a57f4f4 100644
--- a/src/building-app/error-handling.md
+++ b/src/testing-errors/error-handling.md
@@ -14,14 +14,14 @@ Nova lets you register custom handlers for specific HTTP status codes directly i
routes(_Environment) ->
[
#{routes => [
- {404, fun my_first_nova_error_controller:not_found/1, #{}},
- {500, fun my_first_nova_error_controller:server_error/1, #{}}
+ {404, fun blog_error_controller:not_found/1, #{}},
+ {500, fun blog_error_controller:server_error/1, #{}}
]},
#{prefix => "",
security => false,
routes => [
- {"/", fun my_first_nova_main_controller:index/1, #{methods => [get]}},
+ {"/", fun blog_main_controller:index/1, #{methods => [get]}},
{"/heartbeat", fun(_) -> {status, 200} end, #{methods => [get]}}
]
}
@@ -32,10 +32,10 @@ Your status code handlers override Nova's defaults because your routes are compi
## Creating an error controller
-Create `src/controllers/my_first_nova_error_controller.erl`:
+Create `src/controllers/blog_error_controller.erl`:
```erlang
--module(my_first_nova_error_controller).
+-module(blog_error_controller).
-export([
not_found/1,
server_error/1
@@ -85,6 +85,30 @@ not_found(Req) ->
end.
```
+## Rendering changeset errors as JSON
+
+When using Kura, changeset validation errors are structured data. A helper function makes it easy to return them as JSON:
+
+```erlang
+changeset_errors_to_json(#kura_changeset{errors = Errors}) ->
+ maps:from_list([{atom_to_binary(Field), Msg} || {Field, Msg} <- Errors]).
+```
+
+Use it in your controllers:
+
+```erlang
+create(#{params := Params}) ->
+ CS = post:changeset(#{}, Params),
+ case blog_repo:insert(CS) of
+ {ok, Post} ->
+ {json, 201, #{}, post_to_json(Post)};
+ {error, #kura_changeset{} = CS1} ->
+ {json, 422, #{}, #{errors => changeset_errors_to_json(CS1)}}
+ end.
+```
+
+This returns errors like `{"errors": {"title": "can't be blank", "email": "has already been taken"}}`.
+
## Handling controller crashes
When a controller crashes, Nova catches the exception and triggers the 500 handler. The request map passed to your error controller will contain `crash_info`:
@@ -107,11 +131,11 @@ Register handlers for any HTTP status code:
```erlang
#{routes => [
- {400, fun my_first_nova_error_controller:bad_request/1, #{}},
- {401, fun my_first_nova_error_controller:unauthorized/1, #{}},
- {403, fun my_first_nova_error_controller:forbidden/1, #{}},
- {404, fun my_first_nova_error_controller:not_found/1, #{}},
- {500, fun my_first_nova_error_controller:server_error/1, #{}}
+ {400, fun blog_error_controller:bad_request/1, #{}},
+ {401, fun blog_error_controller:unauthorized/1, #{}},
+ {403, fun blog_error_controller:forbidden/1, #{}},
+ {404, fun blog_error_controller:not_found/1, #{}},
+ {500, fun blog_error_controller:server_error/1, #{}}
]}
```
@@ -143,8 +167,8 @@ For each case, Nova looks up your registered status code handler. If none is reg
If a controller returns an unrecognized value, Nova can delegate to a fallback controller:
```erlang
--module(my_first_nova_api_controller).
--fallback_controller(my_first_nova_error_controller).
+-module(blog_posts_controller).
+-fallback_controller(blog_error_controller).
index(_Req) ->
case do_something() of
@@ -173,4 +197,4 @@ To skip Nova's error page rendering entirely:
---
-With error handling in place, our application is more robust. Let's look at how to compose larger applications with [sub-applications](sub-applications.md).
+With error handling in place, our application is more robust. Next, let's add real-time features with [WebSockets](../production/websockets.md).
diff --git a/src/testing-errors/testing.md b/src/testing-errors/testing.md
new file mode 100644
index 0000000..0373d1b
--- /dev/null
+++ b/src/testing-errors/testing.md
@@ -0,0 +1,297 @@
+# Testing
+
+Nova applications can be tested with Erlang's built-in frameworks: EUnit for unit tests and Common Test for integration tests. The [nova_test](https://github.com/novaframework/nova_test) library adds helpers: a request builder for unit testing controllers, an HTTP client for integration tests, and assertion macros.
+
+## Adding nova_test
+
+Add `nova_test` as a test dependency in `rebar.config`:
+
+```erlang
+{profiles, [
+ {test, [
+ {deps, [
+ {nova_test, "0.1.0"}
+ ]}
+ ]}
+]}.
+```
+
+## Database setup for tests
+
+Tests need a running PostgreSQL. Use the same `docker-compose.yml` from the [Database Setup](../data-layer/setup.md) chapter:
+
+```shell
+docker compose up -d
+```
+
+Your test configuration should point at the test database. You can use the same development database for simplicity, or create a separate one for isolation.
+
+## EUnit — Unit testing controllers
+
+Nova controllers are regular Erlang functions that receive a request map and return a tuple. The `nova_test_req` module builds well-formed request maps so you don't have to construct them by hand.
+
+Create `test/blog_posts_controller_tests.erl`:
+
+```erlang
+-module(blog_posts_controller_tests).
+-include_lib("nova_test/include/nova_test.hrl").
+
+show_existing_post_test() ->
+ Req = nova_test_req:new(get, "/api/posts/1"),
+ Req1 = nova_test_req:with_bindings(#{<<"id">> => <<"1">>}, Req),
+ Result = blog_posts_controller:show(Req1),
+ ?assertJsonResponse(#{id := 1, title := _}, Result).
+
+show_missing_post_test() ->
+ Req = nova_test_req:new(get, "/api/posts/999999"),
+ Req1 = nova_test_req:with_bindings(#{<<"id">> => <<"999999">>}, Req),
+ Result = blog_posts_controller:show(Req1),
+ ?assertStatusResponse(404, Result).
+
+create_post_test() ->
+ Req = nova_test_req:new(post, "/api/posts"),
+ Req1 = nova_test_req:with_json(#{<<"title">> => <<"Test Post">>,
+ <<"body">> => <<"Test body">>,
+ <<"user_id">> => 1}, Req),
+ Result = blog_posts_controller:create(Req1),
+ ?assertJsonResponse(201, #{id := _}, Result).
+
+create_invalid_post_test() ->
+ Req = nova_test_req:new(post, "/api/posts"),
+ Req1 = nova_test_req:with_json(#{}, Req),
+ Result = blog_posts_controller:create(Req1),
+ ?assertStatusResponse(422, Result).
+```
+
+### Request builder functions
+
+| Function | Purpose |
+|---|---|
+| `nova_test_req:new/2` | Create a request with method and path |
+| `nova_test_req:with_bindings/2` | Set path bindings (e.g. `#{<<"id">> => <<"1">>}`) |
+| `nova_test_req:with_json/2` | Set a JSON body (auto-encodes, sets content-type) |
+| `nova_test_req:with_header/3` | Add a request header |
+| `nova_test_req:with_query/2` | Set query string parameters |
+| `nova_test_req:with_body/2` | Set a raw body |
+| `nova_test_req:with_auth_data/2` | Set auth data (for testing authenticated controllers) |
+| `nova_test_req:with_peer/2` | Set the client peer address |
+
+Run EUnit tests:
+
+```shell
+rebar3 eunit
+```
+
+## Testing changesets
+
+Changesets are pure functions — no database needed. Test them directly:
+
+```erlang
+-module(post_changeset_tests).
+-include_lib("kura/include/kura.hrl").
+-include_lib("eunit/include/eunit.hrl").
+
+valid_changeset_test() ->
+ CS = post:changeset(#{}, #{<<"title">> => <<"Good Title">>,
+ <<"body">> => <<"Some content">>}),
+ ?assert(CS#kura_changeset.valid).
+
+missing_title_test() ->
+ CS = post:changeset(#{}, #{<<"body">> => <<"Some content">>}),
+ ?assertNot(CS#kura_changeset.valid),
+ ?assertMatch([{title, _} | _], CS#kura_changeset.errors).
+
+title_too_short_test() ->
+ CS = post:changeset(#{}, #{<<"title">> => <<"Hi">>,
+ <<"body">> => <<"Content">>}),
+ ?assertNot(CS#kura_changeset.valid),
+ ?assertMatch([{title, _}], CS#kura_changeset.errors).
+
+invalid_status_test() ->
+ CS = post:changeset(#{}, #{<<"title">> => <<"Good Title">>,
+ <<"body">> => <<"Content">>,
+ <<"status">> => <<"invalid">>}),
+ ?assertNot(CS#kura_changeset.valid).
+
+valid_email_format_test() ->
+ CS = user:changeset(#{}, #{<<"username">> => <<"alice">>,
+ <<"email">> => <<"alice@example.com">>,
+ <<"password_hash">> => <<"hashed">>}),
+ ?assert(CS#kura_changeset.valid).
+
+invalid_email_format_test() ->
+ CS = user:changeset(#{}, #{<<"username">> => <<"alice">>,
+ <<"email">> => <<"not-an-email">>,
+ <<"password_hash">> => <<"hashed">>}),
+ ?assertNot(CS#kura_changeset.valid).
+```
+
+## Testing security modules
+
+Test your security functions directly:
+
+```erlang
+-module(blog_auth_tests).
+-include_lib("nova_test/include/nova_test.hrl").
+
+valid_login_test() ->
+ Req = nova_test_req:new(post, "/login"),
+ Req1 = nova_test_req:with_json(#{<<"username">> => <<"admin">>,
+ <<"password">> => <<"password">>}, Req),
+ ?assertMatch({true, #{authed := true, username := <<"admin">>}},
+ blog_auth:username_password(Req1)).
+
+invalid_password_test() ->
+ Req = nova_test_req:new(post, "/login"),
+ Req1 = nova_test_req:with_json(#{<<"username">> => <<"admin">>,
+ <<"password">> => <<"wrong">>}, Req),
+ ?assertEqual(false, blog_auth:username_password(Req1)).
+
+missing_params_test() ->
+ Req = nova_test_req:new(post, "/login"),
+ ?assertEqual(false, blog_auth:username_password(Req)).
+```
+
+## Common Test — Integration testing
+
+Common Test is better for full-stack tests where you need the application running. `nova_test` provides an HTTP client that handles startup and port discovery.
+
+Create `test/blog_api_SUITE.erl`:
+
+```erlang
+-module(blog_api_SUITE).
+-include_lib("common_test/include/ct.hrl").
+-include_lib("nova_test/include/nova_test.hrl").
+
+-export([
+ all/0,
+ init_per_suite/1,
+ end_per_suite/1,
+ test_list_posts/1,
+ test_create_post/1,
+ test_create_invalid_post/1,
+ test_get_post/1,
+ test_update_post/1,
+ test_delete_post/1,
+ test_get_post_not_found/1
+ ]).
+
+all() ->
+ [test_list_posts,
+ test_create_post,
+ test_create_invalid_post,
+ test_get_post,
+ test_update_post,
+ test_delete_post,
+ test_get_post_not_found].
+
+init_per_suite(Config) ->
+ nova_test:start(blog, Config).
+
+end_per_suite(Config) ->
+ nova_test:stop(Config).
+
+test_list_posts(Config) ->
+ {ok, Resp} = nova_test:get("/api/posts", Config),
+ ?assertStatus(200, Resp),
+ ?assertJson(#{<<"posts">> := _}, Resp).
+
+test_create_post(Config) ->
+ {ok, Resp} = nova_test:post("/api/posts",
+ #{json => #{<<"title">> => <<"Test Post">>,
+ <<"body">> => <<"Test body">>,
+ <<"user_id">> => 1}},
+ Config),
+ ?assertStatus(201, Resp),
+ ?assertJson(#{<<"title">> := <<"Test Post">>}, Resp).
+
+test_create_invalid_post(Config) ->
+ {ok, Resp} = nova_test:post("/api/posts",
+ #{json => #{<<"title">> => <<"Hi">>}},
+ Config),
+ ?assertStatus(422, Resp),
+ ?assertJson(#{<<"errors">> := _}, Resp).
+
+test_get_post(Config) ->
+ %% Create a post first
+ {ok, CreateResp} = nova_test:post("/api/posts",
+ #{json => #{<<"title">> => <<"Get Test">>,
+ <<"body">> => <<"Body">>,
+ <<"user_id">> => 1}},
+ Config),
+ ?assertStatus(201, CreateResp),
+ #{<<"id">> := Id} = nova_test:json(CreateResp),
+
+ %% Fetch it
+ {ok, Resp} = nova_test:get("/api/posts/" ++ integer_to_list(Id), Config),
+ ?assertStatus(200, Resp),
+ ?assertJson(#{<<"title">> := <<"Get Test">>}, Resp).
+
+test_update_post(Config) ->
+ %% Create a post first
+ {ok, CreateResp} = nova_test:post("/api/posts",
+ #{json => #{<<"title">> => <<"Before Update">>,
+ <<"body">> => <<"Body">>,
+ <<"user_id">> => 1}},
+ Config),
+ #{<<"id">> := Id} = nova_test:json(CreateResp),
+
+ %% Update it
+ {ok, Resp} = nova_test:put("/api/posts/" ++ integer_to_list(Id),
+ #{json => #{<<"title">> => <<"After Update">>}},
+ Config),
+ ?assertStatus(200, Resp),
+ ?assertJson(#{<<"title">> := <<"After Update">>}, Resp).
+
+test_delete_post(Config) ->
+ %% Create a post first
+ {ok, CreateResp} = nova_test:post("/api/posts",
+ #{json => #{<<"title">> => <<"To Delete">>,
+ <<"body">> => <<"Body">>,
+ <<"user_id">> => 1}},
+ Config),
+ #{<<"id">> := Id} = nova_test:json(CreateResp),
+
+ %% Delete it
+ {ok, Resp} = nova_test:delete("/api/posts/" ++ integer_to_list(Id), Config),
+ ?assertStatus(204, Resp).
+
+test_get_post_not_found(Config) ->
+ {ok, Resp} = nova_test:get("/api/posts/999999", Config),
+ ?assertStatus(404, Resp).
+```
+
+### Assertion macros
+
+| Macro | Purpose |
+|---|---|
+| `?assertStatus(Code, Resp)` | Assert the HTTP status code |
+| `?assertJson(Pattern, Resp)` | Pattern-match the decoded JSON body |
+| `?assertBody(Expected, Resp)` | Assert the raw response body |
+| `?assertHeader(Name, Expected, Resp)` | Assert a response header value |
+
+Run Common Test suites:
+
+```shell
+rebar3 ct
+```
+
+## Test structure
+
+```
+test/
+├── blog_posts_controller_tests.erl %% EUnit — controller unit tests
+├── post_changeset_tests.erl %% EUnit — changeset validation
+├── blog_auth_tests.erl %% EUnit — security functions
+└── blog_api_SUITE.erl %% Common Test — integration tests
+```
+
+```admonish tip
+- Use EUnit for fast unit tests of individual functions and changesets
+- Use Common Test for integration tests that need the full application running
+- Run both with `rebar3 do eunit, ct`
+```
+
+---
+
+With testing in place, let's look at how to handle errors gracefully in [Error Handling](error-handling.md).