From 29a4a71ac55a252b0cfa4d60c0d018d71edba5cf Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Mon, 23 Feb 2026 01:16:02 +0100 Subject: [PATCH] feat: rewrite nova-book for Kura 1.0 with blog tutorial Replace the notes app with a blog platform that exercises Kura's full feature set: schemas, migrations, changesets, associations, many-to-many, embedded schemas, enum types, transactions, multi, and bulk operations. Restructure from 23 chapters into 20 + 2 appendix with new sections: Data Layer with Kura, Building the API, Testing and Error Handling, Real-Time and Production, and Going Further. Consolidate views/auth/sessions, openapi/inspection, and plugins/cors into single chapters. Remove raw pgo usage and sub-applications chapter. Co-Authored-By: Claude Opus 4.6 --- src/SUMMARY.md | 41 +- src/appendix/cheat-sheet.md | 181 +++++++- src/appendix/erlang-essentials.md | 8 +- src/building-api/advanced-data.md | 280 +++++++++++++ src/building-api/associations.md | 269 ++++++++++++ src/building-api/json-api.md | 255 ++++++++++++ src/building-apis/json-apis.md | 138 ------- src/building-app/crud-app.md | 391 ------------------ src/building-app/sub-applications.md | 116 ------ src/data-and-testing/database-integration.md | 248 ----------- src/data-and-testing/testing.md | 226 ---------- src/data-layer/changesets.md | 183 ++++++++ src/data-layer/crud.md | 259 ++++++++++++ src/data-layer/schemas-migrations.md | 225 ++++++++++ src/data-layer/setup.md | 197 +++++++++ src/developer-tools/code-generators.md | 162 -------- src/developer-tools/inspection-tools.md | 149 ------- src/developer-tools/openapi.md | 194 --------- src/getting-started/authentication.md | 99 ----- src/getting-started/create-app.md | 40 +- src/getting-started/plugins.md | 6 +- src/getting-started/routing.md | 20 +- src/getting-started/sessions.md | 230 ----------- src/getting-started/views-auth-sessions.md | 282 +++++++++++++ src/getting-started/views.md | 82 ---- src/going-further/cors.md | 148 ------- src/going-further/custom-plugins.md | 221 ---------- src/going-further/openapi-tools.md | 267 ++++++++++++ src/going-further/opentelemetry.md | 32 +- src/going-further/plugins-cors.md | 287 +++++++++++++ src/going-further/pubsub.md | 191 --------- src/introduction.md | 22 +- .../deployment.md | 80 ++-- src/production/pubsub.md | 256 ++++++++++++ src/production/transactions-bulk.md | 205 +++++++++ .../websockets.md | 30 +- .../error-handling.md | 50 ++- src/testing-errors/testing.md | 297 +++++++++++++ 38 files changed, 3625 insertions(+), 2742 deletions(-) create mode 100644 src/building-api/advanced-data.md create mode 100644 src/building-api/associations.md create mode 100644 src/building-api/json-api.md delete mode 100644 src/building-apis/json-apis.md delete mode 100644 src/building-app/crud-app.md delete mode 100644 src/building-app/sub-applications.md delete mode 100644 src/data-and-testing/database-integration.md delete mode 100644 src/data-and-testing/testing.md create mode 100644 src/data-layer/changesets.md create mode 100644 src/data-layer/crud.md create mode 100644 src/data-layer/schemas-migrations.md create mode 100644 src/data-layer/setup.md delete mode 100644 src/developer-tools/code-generators.md delete mode 100644 src/developer-tools/inspection-tools.md delete mode 100644 src/developer-tools/openapi.md delete mode 100644 src/getting-started/authentication.md delete mode 100644 src/getting-started/sessions.md create mode 100644 src/getting-started/views-auth-sessions.md delete mode 100644 src/getting-started/views.md delete mode 100644 src/going-further/cors.md delete mode 100644 src/going-further/custom-plugins.md create mode 100644 src/going-further/openapi-tools.md create mode 100644 src/going-further/plugins-cors.md delete mode 100644 src/going-further/pubsub.md rename src/{building-app => production}/deployment.md (73%) create mode 100644 src/production/pubsub.md create mode 100644 src/production/transactions-bulk.md rename src/{building-apis => production}/websockets.md (75%) rename src/{building-app => testing-errors}/error-handling.md (73%) create mode 100644 src/testing-errors/testing.md diff --git a/src/SUMMARY.md b/src/SUMMARY.md index 1df0c4e..3d1437c 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -9,38 +9,37 @@ - [Create a New Application](getting-started/create-app.md) - [Routing](getting-started/routing.md) - [Plugins](getting-started/plugins.md) -- [Views](getting-started/views.md) -- [Authentication](getting-started/authentication.md) -- [Sessions](getting-started/sessions.md) +- [Views, Auth & Sessions](getting-started/views-auth-sessions.md) -# Building APIs +# Data Layer with Kura -- [JSON APIs](building-apis/json-apis.md) -- [WebSockets](building-apis/websockets.md) +- [Database Setup](data-layer/setup.md) +- [Schemas and Migrations](data-layer/schemas-migrations.md) +- [Changesets and Validation](data-layer/changesets.md) +- [CRUD with the Repository](data-layer/crud.md) -# Data and Testing +# Building the API -- [Database Integration](data-and-testing/database-integration.md) -- [Testing](data-and-testing/testing.md) +- [JSON API with Generators](building-api/json-api.md) +- [Associations and Preloading](building-api/associations.md) +- [Tags, Many-to-Many & Embedded Schemas](building-api/advanced-data.md) -# Building a Complete Application +# Testing and Error Handling -- [CRUD Application](building-app/crud-app.md) -- [Error Handling](building-app/error-handling.md) -- [Sub-Applications](building-app/sub-applications.md) -- [Deployment](building-app/deployment.md) +- [Testing](testing-errors/testing.md) +- [Error Handling](testing-errors/error-handling.md) -# Developer Tools +# Real-Time and Production -- [Code Generators](developer-tools/code-generators.md) -- [OpenAPI & API Documentation](developer-tools/openapi.md) -- [Inspection & Audit Tools](developer-tools/inspection-tools.md) +- [WebSockets](production/websockets.md) +- [Pub/Sub and Real-Time Feed](production/pubsub.md) +- [Transactions, Multi & Bulk Operations](production/transactions-bulk.md) +- [Deployment](production/deployment.md) # Going Further -- [Pub/Sub](going-further/pubsub.md) -- [Custom Plugins](going-further/custom-plugins.md) -- [CORS](going-further/cors.md) +- [OpenAPI, Inspection & Audit](going-further/openapi-tools.md) +- [Custom Plugins and CORS](going-further/plugins-cors.md) - [OpenTelemetry](going-further/opentelemetry.md) --- diff --git a/src/appendix/cheat-sheet.md b/src/appendix/cheat-sheet.md index 5d914e5..2e5a6c0 100644 --- a/src/appendix/cheat-sheet.md +++ b/src/appendix/cheat-sheet.md @@ -1,6 +1,6 @@ # Cheat Sheet -Quick reference for Nova's APIs, return values, and configuration. +Quick reference for Nova's APIs, return values, configuration, and Kura's database layer. ## Controller return tuples @@ -185,11 +185,175 @@ nova_pubsub:get_local_members(Channel) ]} ``` +--- + +## Kura — Schema definition + +```erlang +-module(my_schema). +-behaviour(kura_schema). +-include_lib("kura/include/kura.hrl"). +-export([table/0, fields/0, primary_key/0, associations/0, embeds/0]). + +table() -> <<"my_table">>. +primary_key() -> id. + +fields() -> + [ + #kura_field{name = id, type = id, primary_key = true, nullable = false}, + #kura_field{name = name, type = string, nullable = false}, + #kura_field{name = status, type = {enum, [active, inactive]}}, + #kura_field{name = metadata, type = {embed, embeds_one, metadata_schema}}, + #kura_field{name = inserted_at, type = utc_datetime}, + #kura_field{name = updated_at, type = utc_datetime} + ]. + +associations() -> + [ + #kura_assoc{name = author, type = belongs_to, schema = user, foreign_key = author_id}, + #kura_assoc{name = comments, type = has_many, schema = comment, foreign_key = post_id}, + #kura_assoc{name = tags, type = many_to_many, schema = tag, + join_through = <<"posts_tags">>, join_keys = {post_id, tag_id}} + ]. + +embeds() -> + [#kura_embed{name = metadata, type = embeds_one, schema = metadata_schema}]. +``` + +### 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 | +| `{embed, embeds_one, Mod}` | `JSONB` | map | +| `{embed, embeds_many, Mod}` | `JSONB` | list of maps | + +## Kura — Changeset API + +```erlang +%% Create a changeset +CS = kura_changeset:cast(SchemaModule, ExistingData, Params, AllowedFields). + +%% Validations +kura_changeset:validate_required(CS, [field1, field2]) +kura_changeset:validate_format(CS, field, "regex") +kura_changeset:validate_length(CS, field, [{min, 3}, {max, 200}]) +kura_changeset:validate_number(CS, field, [{greater_than, 0}]) +kura_changeset:validate_inclusion(CS, field, [val1, val2, val3]) +kura_changeset:validate_change(CS, field, fun(Val) -> ok | {error, Msg} end) + +%% Constraint declarations +kura_changeset:unique_constraint(CS, field) +kura_changeset:foreign_key_constraint(CS, field) +kura_changeset:check_constraint(CS, ConstraintName, field, #{message => Msg}) + +%% Association/embed casting +kura_changeset:cast_assoc(CS, assoc_name) +kura_changeset:cast_assoc(CS, assoc_name, #{with => Fun}) +kura_changeset:put_assoc(CS, assoc_name, Value) +kura_changeset:cast_embed(CS, embed_name) + +%% Changeset helpers +kura_changeset:get_change(CS, field) -> Value | undefined +kura_changeset:get_field(CS, field) -> Value | undefined +kura_changeset:put_change(CS, field, Val) -> CS1 +kura_changeset:add_error(CS, field, Msg) -> CS1 +kura_changeset:apply_changes(CS) -> DataMap +kura_changeset:apply_action(CS, Action) -> {ok, Data} | {error, CS} +``` + +### Schemaless changesets + +```erlang +Types = #{email => string, age => integer}, +CS = kura_changeset:cast(Types, #{}, Params, [email, age]). +``` + +## Kura — Query builder + +```erlang +Q = kura_query:from(schema_module), + +%% Where conditions +Q1 = kura_query:where(Q, {field, value}), %% = +Q1 = kura_query:where(Q, {field, '>', value}), %% comparison +Q1 = kura_query:where(Q, {field, in, [val1, val2]}), %% IN +Q1 = kura_query:where(Q, {field, ilike, <<"%term%">>}), %% ILIKE +Q1 = kura_query:where(Q, {field, is_nil}), %% IS NULL +Q1 = kura_query:where(Q, {'or', [{f1, v1}, {f2, v2}]}), %% OR + +%% Ordering, pagination +Q2 = kura_query:order_by(Q, [{field, asc}]), +Q3 = kura_query:limit(Q, 10), +Q4 = kura_query:offset(Q, 20), + +%% Preloading associations +Q5 = kura_query:preload(Q, [author, {comments, [author]}]). +``` + +## Kura — Repository API + +```erlang +%% Read +blog_repo:all(Query) -> {ok, [Map]} +blog_repo:get(Schema, Id) -> {ok, Map} | {error, not_found} +blog_repo:get_by(Schema, Clauses) -> {ok, Map} | {error, not_found} +blog_repo:one(Query) -> {ok, Map} | {error, not_found} + +%% Write +blog_repo:insert(Changeset) -> {ok, Map} | {error, Changeset} +blog_repo:insert(Changeset, Opts) -> {ok, Map} | {error, Changeset} +blog_repo:update(Changeset) -> {ok, Map} | {error, Changeset} +blog_repo:delete(Changeset) -> {ok, Map} | {error, Changeset} + +%% Bulk +blog_repo:insert_all(Schema, [Map]) -> {ok, Count} +blog_repo:update_all(Query, Updates) -> {ok, Count} +blog_repo:delete_all(Query) -> {ok, Count} + +%% Preloading +blog_repo:preload(Schema, Records, Assocs) -> Records + +%% Transactions +blog_repo:transaction(Fun) -> {ok, Result} | {error, Reason} +blog_repo:multi(Multi) -> {ok, Results} | {error, Step, Value, Completed} +``` + +### Upsert options + +```erlang +blog_repo:insert(CS, #{on_conflict => {field, nothing}}) +blog_repo:insert(CS, #{on_conflict => {field, replace_all}}) +blog_repo:insert(CS, #{on_conflict => {field, {replace, [fields]}}}) +``` + +## Kura — Multi (transaction pipelines) + +```erlang +M = kura_multi:new(), +M1 = kura_multi:insert(M, step_name, Changeset), +M2 = kura_multi:update(M1, step_name, fun(Results) -> Changeset end), +M3 = kura_multi:delete(M2, step_name, Changeset), +M4 = kura_multi:run(M3, step_name, fun(Results) -> {ok, Value} end), +{ok, #{step1 := V1, step2 := V2}} = blog_repo:multi(M4). +``` + ## Common rebar3 commands | Command | Description | |---|---| -| `rebar3 compile` | Compile the project | +| `rebar3 compile` | Compile the project (also triggers kura migration generation) | | `rebar3 shell` | Start interactive shell | | `rebar3 nova serve` | Dev server with hot-reload | | `rebar3 nova routes` | List registered routes | @@ -200,7 +364,7 @@ nova_pubsub:get_local_members(Channel) | `rebar3 as prod tar` | Build release tarball | | `rebar3 dialyzer` | Run type checker | -## Nova developer tool commands +## rebar3_nova commands | Command | Description | |---|---| @@ -213,6 +377,13 @@ nova_pubsub:get_local_members(Channel) | `rebar3 nova audit` | Find routes missing security callbacks | | `rebar3 nova release` | Build release with auto-generated OpenAPI | +## rebar3_kura commands + +| Command | Description | +|---|---| +| `rebar3 kura setup --name REPO` | Generate a repo module and migrations directory | +| `rebar3 kura compile` | Diff schemas vs migrations and generate new migrations | + ### Generator options ```shell @@ -222,6 +393,6 @@ rebar3 nova gen_controller --name products --actions list,show,create # OpenAPI with custom output rebar3 nova openapi --output priv/assets/openapi.json --title "My API" --api-version 1.0.0 -# Release with specific profile -rebar3 nova release --profile staging +# Kura setup with custom repo name +rebar3 kura setup --name my_repo ``` diff --git a/src/appendix/erlang-essentials.md b/src/appendix/erlang-essentials.md index 51c6cf9..d11fe49 100644 --- a/src/appendix/erlang-essentials.md +++ b/src/appendix/erlang-essentials.md @@ -89,9 +89,9 @@ handle(#{method := <<"GET">>} = Req) -> get_handler(Req); handle(#{method := <<"POST">>} = Req) -> post_handler(Req). %% Case expression -case pgo:query("SELECT ...") of - #{rows := [Row]} -> {ok, Row}; - #{rows := []} -> {error, not_found} +case blog_repo:get(post, Id) of + {ok, Post} -> handle_post(Post); + {error, not_found} -> not_found end. ``` @@ -140,7 +140,7 @@ An OTP application is a component with a defined start/stop lifecycle. Your Nova ### Supervisors -Supervisors manage child processes and restart them if they crash. The generated `my_first_nova_sup.erl` is your application's supervisor. +Supervisors manage child processes and restart them if they crash. The generated `blog_sup.erl` is your application's supervisor. ### gen_server diff --git a/src/building-api/advanced-data.md b/src/building-api/advanced-data.md new file mode 100644 index 0000000..c83f6f1 --- /dev/null +++ b/src/building-api/advanced-data.md @@ -0,0 +1,280 @@ +# Tags, Many-to-Many & Embedded Schemas + +Our blog has users, posts, and comments. Now let's add tags (many-to-many through a join table) and post metadata (embedded schema stored as JSONB). + +## Tag schema + +Create `src/schemas/tag.erl`: + +```erlang +-module(tag). +-behaviour(kura_schema). +-include_lib("kura/include/kura.hrl"). + +-export([table/0, fields/0, primary_key/0, associations/0, changeset/2]). + +table() -> <<"tags">>. + +primary_key() -> id. + +fields() -> + [ + #kura_field{name = id, type = id, primary_key = true, nullable = false}, + #kura_field{name = name, type = string, nullable = false}, + #kura_field{name = inserted_at, type = utc_datetime} + ]. + +associations() -> + [ + #kura_assoc{name = posts, type = many_to_many, schema = post, + join_through = <<"posts_tags">>, join_keys = {tag_id, post_id}} + ]. + +changeset(Data, Params) -> + CS = kura_changeset:cast(tag, Data, Params, [name]), + CS1 = kura_changeset:validate_required(CS, [name]), + kura_changeset:unique_constraint(CS1, name). +``` + +## Join table schema + +The many-to-many relationship needs a join table. Create `src/schemas/posts_tags.erl`: + +```erlang +-module(posts_tags). +-behaviour(kura_schema). +-include_lib("kura/include/kura.hrl"). + +-export([table/0, fields/0, primary_key/0]). + +table() -> <<"posts_tags">>. + +primary_key() -> id. + +fields() -> + [ + #kura_field{name = id, type = id, primary_key = true, nullable = false}, + #kura_field{name = post_id, type = integer, nullable = false}, + #kura_field{name = tag_id, type = integer, nullable = false} + ]. +``` + +## Adding many-to-many to posts + +Update the `associations/0` in `src/schemas/post.erl`: + +```erlang +associations() -> + [ + #kura_assoc{name = author, type = belongs_to, schema = user, foreign_key = user_id}, + #kura_assoc{name = comments, type = has_many, schema = comment, foreign_key = post_id}, + #kura_assoc{name = tags, type = many_to_many, schema = tag, + join_through = <<"posts_tags">>, join_keys = {post_id, tag_id}} + ]. +``` + +The `many_to_many` association specifies: +- `join_through` — the join table name +- `join_keys` — `{this_side_fk, other_side_fk}` on the join table + +## Generate the migrations + +Compile to generate the new tables: + +```shell +rebar3 compile +``` + +``` +===> [kura] Schema diff detected changes +===> [kura] Generated src/migrations/m20260223140000_create_tags.erl +===> [kura] Generated src/migrations/m20260223140100_create_posts_tags.erl +``` + +## Tagging posts with put_assoc + +Use `put_assoc` to set tags on a post: + +```erlang +%% Get existing tags (or create new ones first) +{ok, Erlang} = blog_repo:get_by(tag, [{name, <<"erlang">>}]), +{ok, Nova} = blog_repo:get_by(tag, [{name, <<"nova">>}]), + +%% Assign tags to a post +{ok, Post} = blog_repo:get(post, 1), +CS = kura_changeset:cast(post, Post, #{}, []), +CS1 = kura_changeset:put_assoc(CS, tags, [Erlang, Nova]), +{ok, _} = blog_repo:update(CS1). +``` + +`put_assoc` replaces the entire association — under the hood it deletes existing join table rows and inserts new ones, all in a transaction. + +## Preloading tags + +```erlang +Q = kura_query:from(post), +Q1 = kura_query:preload(Q, [author, tags]), +{ok, Posts} = blog_repo:all(Q1). +``` + +Each post now has a `tags` key with a list of tag maps: + +```erlang +#{id => 1, title => <<"My First Post">>, + tags => [#{id => 1, name => <<"erlang">>}, #{id => 2, name => <<"nova">>}], + ...} +``` + +## Embedded schemas + +Sometimes you need structured data that doesn't deserve its own table. Kura's embedded schemas store nested structures as JSONB columns. + +### Post metadata + +Create `src/schemas/post_metadata.erl`: + +```erlang +-module(post_metadata). +-behaviour(kura_schema). +-include_lib("kura/include/kura.hrl"). + +-export([table/0, fields/0, primary_key/0, changeset/2]). + +table() -> <<"embedded">>. + +primary_key() -> undefined. + +fields() -> + [ + #kura_field{name = meta_title, type = string}, + #kura_field{name = meta_description, type = string}, + #kura_field{name = og_image, type = string} + ]. + +changeset(Data, Params) -> + CS = kura_changeset:cast(post_metadata, Data, Params, + [meta_title, meta_description, og_image]), + kura_changeset:validate_length(CS, meta_description, [{max, 160}]). +``` + +The embedded schema looks like a regular schema but with `table()` returning a placeholder (it's never queried directly) and `primary_key()` returning `undefined`. + +### Adding the embed to posts + +Update `src/schemas/post.erl` to add an `embeds/0` callback and a `metadata` JSONB field: + +```erlang +-export([table/0, fields/0, primary_key/0, associations/0, embeds/0, changeset/2]). + +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 = metadata, type = {embed, embeds_one, post_metadata}}, + #kura_field{name = inserted_at, type = utc_datetime}, + #kura_field{name = updated_at, type = utc_datetime} + ]. + +embeds() -> + [ + #kura_embed{name = metadata, type = embeds_one, schema = post_metadata} + ]. +``` + +Compile to generate a migration that adds the `metadata` JSONB column: + +```shell +rebar3 compile +``` + +### Using embedded schemas + +Cast the embed in your changeset: + +```erlang +changeset(Data, Params) -> + CS = kura_changeset:cast(post, Data, Params, [title, body, status, user_id, metadata]), + CS1 = kura_changeset:validate_required(CS, [title, body]), + CS2 = kura_changeset:validate_length(CS1, title, [{min, 3}, {max, 200}]), + CS3 = kura_changeset:validate_inclusion(CS2, status, [draft, published, archived]), + CS4 = kura_changeset:foreign_key_constraint(CS3, user_id), + kura_changeset:cast_embed(CS4, metadata). +``` + +`cast_embed` reads the `metadata` key from params and builds a nested changeset using `post_metadata:changeset/2`. Create a post with metadata: + +```shell +curl -s -X POST localhost:8080/api/posts \ + -H "Content-Type: application/json" \ + -d '{ + "title": "SEO Optimized Post", + "body": "Great content here", + "user_id": 1, + "metadata": { + "meta_title": "Best Post Ever", + "meta_description": "A post about great things", + "og_image": "https://example.com/image.jpg" + } + }' | python3 -m json.tool +``` + +The metadata is stored as JSONB in PostgreSQL and loaded back as a nested map: + +```erlang +#{id => 5, + title => <<"SEO Optimized Post">>, + metadata => #{meta_title => <<"Best Post Ever">>, + meta_description => <<"A post about great things">>, + og_image => <<"https://example.com/image.jpg">>}, + ...} +``` + +## Filtering by tag + +To find posts with a specific tag, use a raw SQL fragment or build the query through the join table: + +```erlang +%% Find all post IDs for a given tag +find_posts_by_tag(TagName) -> + {ok, Tag} = blog_repo:get_by(tag, [{name, TagName}]), + TagId = maps:get(id, Tag), + Q = kura_query:from(posts_tags), + Q1 = kura_query:where(Q, {tag_id, TagId}), + {ok, JoinRows} = blog_repo:all(Q1), + PostIds = [maps:get(post_id, R) || R <- JoinRows], + Q2 = kura_query:from(post), + Q3 = kura_query:where(Q2, {id, in, PostIds}), + Q4 = kura_query:preload(Q3, [author, tags]), + blog_repo:all(Q4). +``` + +## API endpoint for tags + +Add a simple tags controller: + +```erlang +-module(blog_tags_controller). +-export([index/1, create/1]). + +index(_Req) -> + Q = kura_query:from(tag), + Q1 = kura_query:order_by(Q, [{name, asc}]), + {ok, Tags} = blog_repo:all(Q1), + {json, #{tags => Tags}}. + +create(#{params := Params}) -> + CS = tag:changeset(#{}, Params), + case blog_repo:insert(CS) of + {ok, Tag} -> + {json, 201, #{}, Tag}; + {error, _CS} -> + {status, 422, #{}, #{error => <<"invalid tag">>}} + end. +``` + +--- + +We now have a rich data model with associations, many-to-many relationships, and embedded schemas. Next, let's write proper [tests](../testing-errors/testing.md) for our application. diff --git a/src/building-api/associations.md b/src/building-api/associations.md new file mode 100644 index 0000000..e9842ca --- /dev/null +++ b/src/building-api/associations.md @@ -0,0 +1,269 @@ +# Associations and Preloading + +So far our posts exist in isolation. In a real blog, posts belong to users and have comments. Kura supports `belongs_to`, `has_many`, `has_one`, and `many_to_many` associations with automatic preloading. + +## Adding associations to schemas + +### Post belongs to user + +Update `src/schemas/post.erl` to add associations: + +```erlang +-module(post). +-behaviour(kura_schema). +-include_lib("kura/include/kura.hrl"). + +-export([table/0, fields/0, primary_key/0, associations/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} + ]. + +associations() -> + [ + #kura_assoc{name = author, type = belongs_to, schema = user, foreign_key = user_id}, + #kura_assoc{name = comments, type = has_many, schema = comment, foreign_key = post_id} + ]. + +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}]), + CS3 = kura_changeset:validate_inclusion(CS2, status, [draft, published, archived]), + kura_changeset:foreign_key_constraint(CS3, user_id). +``` + +The `associations/0` callback returns a list of `#kura_assoc{}` records: + +- **`belongs_to`** — the foreign key (`user_id`) is on this table. `schema` is the associated module, `foreign_key` is the column. +- **`has_many`** — the foreign key (`post_id`) is on the other table. + +We also added `foreign_key_constraint/2` to the changeset — if an insert fails because the user doesn't exist, Kura maps the PostgreSQL foreign key error to a friendly changeset error. + +### Comment schema + +Create `src/schemas/comment.erl`: + +```erlang +-module(comment). +-behaviour(kura_schema). +-include_lib("kura/include/kura.hrl"). + +-export([table/0, fields/0, primary_key/0, associations/0, changeset/2]). + +table() -> <<"comments">>. + +primary_key() -> id. + +fields() -> + [ + #kura_field{name = id, type = id, primary_key = true, nullable = false}, + #kura_field{name = body, type = text, nullable = false}, + #kura_field{name = post_id, type = integer, nullable = false}, + #kura_field{name = user_id, type = integer, nullable = false}, + #kura_field{name = inserted_at, type = utc_datetime}, + #kura_field{name = updated_at, type = utc_datetime} + ]. + +associations() -> + [ + #kura_assoc{name = post, type = belongs_to, schema = post, foreign_key = post_id}, + #kura_assoc{name = author, type = belongs_to, schema = user, foreign_key = user_id} + ]. + +changeset(Data, Params) -> + CS = kura_changeset:cast(comment, Data, Params, [body, post_id, user_id]), + CS1 = kura_changeset:validate_required(CS, [body, post_id, user_id]), + CS2 = kura_changeset:foreign_key_constraint(CS1, post_id), + kura_changeset:foreign_key_constraint(CS2, user_id). +``` + +### User has many posts + +Update `src/schemas/user.erl` to add the `has_many` side: + +```erlang +-export([table/0, fields/0, primary_key/0, associations/0, changeset/2]). + +%% ... fields() unchanged ... + +associations() -> + [ + #kura_assoc{name = posts, type = has_many, schema = post, foreign_key = user_id} + ]. + +%% ... changeset/2 unchanged ... +``` + +## Generate the migration + +Compile to generate the comments table migration: + +```shell +rebar3 compile +``` + +``` +===> [kura] Schema diff detected changes +===> [kura] Generated src/migrations/m20260223130000_create_comments.erl +``` + +The migration creates the `comments` table with foreign keys to `posts` and `users`. + +## Preloading associations + +By default, fetching a post returns only its own fields — associations are not loaded. Use `kura_query:preload/2` to eagerly load them. + +### Preload via query + +```erlang +Q = kura_query:from(post), +Q1 = kura_query:preload(Q, [author, comments]), +{ok, Posts} = blog_repo:all(Q1). +``` + +Each post in `Posts` now has `author` and `comments` keys: + +```erlang +#{id => 1, + title => <<"My First Post">>, + author => #{id => 1, username => <<"alice">>, email => <<"alice@example.com">>, ...}, + comments => [ + #{id => 1, body => <<"Great post!">>, user_id => 2, ...}, + #{id => 2, body => <<"Thanks!">>, user_id => 1, ...} + ], + ...} +``` + +### Nested preloading + +Load the author of each comment too: + +```erlang +Q = kura_query:from(post), +Q1 = kura_query:preload(Q, [author, {comments, [author]}]), +{ok, Posts} = blog_repo:all(Q1). +``` + +Now each comment also has its `author` loaded. + +### Standalone preload + +If you already have records and want to preload associations after the fact: + +```erlang +{ok, Post} = blog_repo:get(post, 1), +Post1 = blog_repo:preload(post, Post, [author, comments]). + +%% Works with lists too +{ok, Posts} = blog_repo:all(kura_query:from(post)), +Posts1 = blog_repo:preload(post, Posts, [author]). +``` + +```admonish info +Kura uses `WHERE IN` queries for preloading — not JOINs. This means one extra query per association, which keeps things predictable and avoids N+1 problems. +``` + +## Creating with associations (cast_assoc) + +You can create a post with comments in a single request using `cast_assoc`: + +```erlang +Params = #{<<"title">> => <<"New Post">>, + <<"body">> => <<"Content here">>, + <<"comments">> => [ + #{<<"body">> => <<"First comment">>, <<"user_id">> => 2} + ]}, +CS = kura_changeset:cast(post, #{}, Params, [title, body, user_id]), +CS1 = kura_changeset:validate_required(CS, [title, body]), +CS2 = kura_changeset:cast_assoc(CS1, comments), +{ok, Post} = blog_repo:insert(CS2). +``` + +`cast_assoc` reads the `comments` key from the params, builds child changesets using `comment:changeset/2`, and wraps everything in a transaction. The parent is inserted first, then each child gets the parent's ID set as its foreign key. + +### Custom cast function + +If you need different validation for nested creates: + +```erlang +CS2 = kura_changeset:cast_assoc(CS1, comments, #{ + with => fun(Data, ChildParams) -> + comment:changeset(Data, ChildParams) + end +}). +``` + +## API endpoint with preloading + +Update the posts controller to return posts with their author and comments: + +```erlang +show(#{bindings := #{<<"id">> := Id}}) -> + case blog_repo:get(post, binary_to_integer(Id)) of + {ok, Post} -> + Post1 = blog_repo:preload(post, Post, [author, {comments, [author]}]), + {json, post_with_assocs_to_json(Post1)}; + {error, not_found} -> + {status, 404, #{}, #{error => <<"post not found">>}} + end. + +post_with_assocs_to_json(#{id := Id, title := Title, body := Body, + status := Status, author := Author, + comments := Comments}) -> + #{id => Id, + title => Title, + body => Body, + status => atom_to_binary(Status), + author => #{id => maps:get(id, Author), + username => maps:get(username, Author)}, + comments => [#{id => maps:get(id, C), + body => maps:get(body, C), + author => #{id => maps:get(id, maps:get(author, C)), + username => maps:get(username, maps:get(author, C))}} + || C <- Comments]}. +``` + +Test it: + +```shell +curl -s localhost:8080/api/posts/1 | python3 -m json.tool +``` + +```json +{ + "id": 1, + "title": "My First Post", + "body": "Hello from Nova!", + "status": "draft", + "author": { + "id": 1, + "username": "alice" + }, + "comments": [ + { + "id": 1, + "body": "Great post!", + "author": { + "id": 2, + "username": "bob" + } + } + ] +} +``` + +--- + +Next, let's add [tags, many-to-many relationships, and embedded schemas](advanced-data.md) for post metadata. diff --git a/src/building-api/json-api.md b/src/building-api/json-api.md new file mode 100644 index 0000000..190e1e0 --- /dev/null +++ b/src/building-api/json-api.md @@ -0,0 +1,255 @@ +# JSON API with Generators + +In the previous chapter we built a posts controller by hand. The `rebar3_nova` plugin includes generators that scaffold controllers, JSON schemas, and test suites so you can skip the boilerplate. + +## Generate a resource + +The `nova gen_resource` command creates a controller, a JSON schema, and prints route definitions: + +```shell +rebar3 nova gen_resource --name posts +===> Writing src/controllers/blog_posts_controller.erl +===> Writing priv/schemas/post.json + +Add these routes to your router: + + {<<"/posts">>, {blog_posts_controller, list}, #{methods => [get]}} + {<<"/posts/:id">>, {blog_posts_controller, show}, #{methods => [get]}} + {<<"/posts">>, {blog_posts_controller, create}, #{methods => [post]}} + {<<"/posts/:id">>, {blog_posts_controller, update}, #{methods => [put]}} + {<<"/posts/:id">>, {blog_posts_controller, delete}, #{methods => [delete]}} +``` + +### The generated controller + +```erlang +-module(blog_posts_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. + +### The generated JSON schema + +`priv/schemas/post.json`: + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" } + }, + "required": ["id", "name"] +} +``` + +Edit this to match your actual data model: + +```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"] }, + "user_id": { "type": "integer", "description": "Author ID" } + }, + "required": ["title", "body"] +} +``` + +This schema is picked up by the [OpenAPI generator](../going-further/openapi-tools.md) to produce API documentation automatically. + +## Filling in Kura calls + +Replace the TODO stubs with actual Kura repo calls. Since we already wrote a full posts controller in the [CRUD chapter](../data-layer/crud.md), here is the pattern — generate, then fill in: + +```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}) -> + #{id => Id, title => Title, body => Body, + status => atom_to_binary(Status), user_id => UserId}. + +changeset_errors_to_json(#kura_changeset{errors = Errors}) -> + maps:from_list([{atom_to_binary(Field), Msg} || {Field, Msg} <- Errors]). +``` + +## Generate a test suite + +The `nova gen_test` command scaffolds a Common Test suite: + +```shell +rebar3 nova gen_test --name posts +===> Writing test/blog_posts_controller_SUITE.erl +``` + +The generated suite has test cases for each CRUD action that make HTTP requests against your running application: + +```erlang +-module(blog_posts_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(blog), + Config. + +end_per_suite(_Config) -> + ok. + +test_list(_Config) -> + {ok, {{_, 200, _}, _, _Body}} = + httpc:request(get, {"http://localhost:8080/posts", []}, [], []). + +test_show(_Config) -> + {ok, {{_, 200, _}, _, _Body}} = + httpc:request(get, {"http://localhost:8080/posts/1", []}, [], []). + +test_create(_Config) -> + {ok, {{_, 201, _}, _, _Body}} = + httpc:request(post, {"http://localhost:8080/posts", [], + "application/json", "{}"}, [], []). + +test_update(_Config) -> + {ok, {{_, 200, _}, _, _Body}} = + httpc:request(put, {"http://localhost:8080/posts/1", [], + "application/json", "{}"}, [], []). + +test_delete(_Config) -> + {ok, {{_, 204, _}, _, _Body}} = + httpc:request(delete, {"http://localhost:8080/posts/1", []}, [], []). +``` + +Update the request bodies and assertions to match your actual API. We will cover testing in detail in the [Testing](../testing-errors/testing.md) chapter. + +## Other generators + +Generate a controller with specific actions: + +```shell +rebar3 nova gen_controller --name comments --actions list,create +===> Writing src/controllers/blog_comments_controller.erl +``` + +## Typical workflow + +Adding a new resource to your API: + +```shell +# 1. Define the Kura schema +vi src/schemas/comment.erl + +# 2. Compile to generate the migration +rebar3 compile + +# 3. Generate the resource (controller + schema + route hints) +rebar3 nova gen_resource --name comments + +# 4. Copy the printed routes into your router + +# 5. Fill in the Kura repo calls in the controller + +# 6. Generate a test suite +rebar3 nova gen_test --name comments + +# 7. Run the tests +rebar3 ct +``` + +Generate, fill in the Kura calls, test. Three steps to a working API. + +--- + +Our posts API works with flat data. Next, let's add [associations and preloading](associations.md) to connect posts to users and comments. diff --git a/src/building-apis/json-apis.md b/src/building-apis/json-apis.md deleted file mode 100644 index 7e7036e..0000000 --- a/src/building-apis/json-apis.md +++ /dev/null @@ -1,138 +0,0 @@ -# JSON APIs - -So far we have been rendering HTML views with ErlyDTL templates. Now let's build a REST API that returns JSON. - -## The JSON return tuple - -Instead of returning `{ok, Variables}` (which renders a template), return `{json, Data}` and Nova encodes it as JSON with the correct `Content-Type` header. - -You can scaffold an API controller quickly with the code generator: - -```shell -rebar3 nova gen_resource --name users --actions index,show,create -``` - -This generates a controller with stub functions, a JSON schema, and prints route definitions. See [Code Generators](../developer-tools/code-generators.md) for the full details. - -Let's write the controller by hand so we can see what each part does. Create `src/controllers/my_first_nova_api_controller.erl`: - -```erlang --module(my_first_nova_api_controller). --export([ - index/1, - show/1, - create/1 - ]). - -index(_Req) -> - Users = [ - #{id => 1, name => <<"Alice">>, email => <<"alice@example.com">>}, - #{id => 2, name => <<"Bob">>, email => <<"bob@example.com">>} - ], - {json, #{users => Users}}. - -show(#{bindings := #{<<"id">> := Id}}) -> - {json, #{id => binary_to_integer(Id), name => <<"Alice">>, email => <<"alice@example.com">>}}; -show(_Req) -> - {status, 400, #{}, #{error => <<"missing id">>}}. - -create(#{params := #{<<"name">> := Name, <<"email">> := Email}}) -> - {json, 201, #{}, #{id => 3, name => Name, email => Email}}; -create(_Req) -> - {status, 422, #{}, #{error => <<"name and email required">>}}. -``` - -Here is what each function does: - -- **`index/1`** — returns a list of users as JSON with status 200 -- **`show/1`** — uses `bindings` from a route with a path parameter like `/users/:id` -- **`create/1`** — reads `params` from the decoded request body and returns `{json, 201, #{}, Data}` to set a custom status code - -## Adding the routes - -Use a prefix to group API 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]}} - ] -} -``` - -The full paths become `/api/users` and `/api/users/:id`. - -## Configuring JSON decoding - -For POST endpoints, Nova needs to decode incoming JSON bodies. Update the plugin configuration in `dev_sys.config.src`: - -```erlang -{plugins, [ - {pre_request, nova_request_plugin, #{ - decode_json_body => true, - read_urlencoded_body => true - }} -]} -``` - -With `decode_json_body => true`, the plugin decodes incoming JSON and puts it in the `params` key of the request map. - -## JSON library - -Nova uses `thoas` as the default JSON encoder/decoder. To use a different library: - -```erlang -{my_first_nova, [ - {json_lib, jsx} -]} -``` - -The library module needs to export `encode/1` and `decode/1`. - -## Testing with curl - -Start the node and test: - -```shell -# Get all users -curl -s localhost:8080/api/users | python3 -m json.tool - -# Get a single user -curl -s localhost:8080/api/users/1 | python3 -m json.tool - -# Create a user -curl -s -X POST localhost:8080/api/users \ - -H "Content-Type: application/json" \ - -d '{"name": "Charlie", "email": "charlie@example.com"}' | python3 -m json.tool -``` - -## Response format reference - -```erlang -%% Simple JSON response (status 200) -{json, #{key => value}} - -%% JSON with custom status code and optional headers -{json, StatusCode, Headers, Body} - -%% Status response (also encodes maps as JSON) -{status, StatusCode} -{status, StatusCode, Headers, Body} - -%% Redirect -{redirect, "/some/path"} -``` - -Adding custom headers: - -```erlang -index(_Req) -> - {json, 200, #{<<"x-request-id">> => <<"abc123">>}, #{users => []}}. -``` - ---- - -JSON APIs are great for structured data, but sometimes you need real-time communication. Let's look at [WebSockets](websockets.md). diff --git a/src/building-app/crud-app.md b/src/building-app/crud-app.md deleted file mode 100644 index 3238b6c..0000000 --- a/src/building-app/crud-app.md +++ /dev/null @@ -1,391 +0,0 @@ -# CRUD Application - -Time to tie everything together. In this chapter we build a complete CRUD (Create, Read, Update, Delete) notes application with both an HTML frontend and a JSON API backend. - -## The plan - -We will build: -- HTML pages for listing, creating, and editing notes -- JSON API endpoints for the same operations -- Database persistence with PostgreSQL -- Authentication for the HTML pages - -## Database setup - -Create the notes table: - -```sql -CREATE TABLE notes ( - id SERIAL PRIMARY KEY, - title VARCHAR(255) NOT NULL, - body TEXT, - author VARCHAR(255), - inserted_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() -); -``` - -## Repository module - -Create `src/my_first_nova_note_repo.erl`: - -```erlang --module(my_first_nova_note_repo). --export([ - all/0, - get/1, - create/3, - update/3, - delete/1 - ]). - -all() -> - case pgo:query("SELECT id, title, body, author, inserted_at FROM notes ORDER BY inserted_at DESC") of - #{rows := Rows} -> - {ok, [row_to_map(Row) || Row <- Rows]}; - {error, Reason} -> - {error, Reason} - end. - -get(Id) -> - case pgo:query("SELECT id, title, body, author, inserted_at FROM notes WHERE id = $1", [Id]) of - #{rows := [Row]} -> - {ok, row_to_map(Row)}; - #{rows := []} -> - {error, not_found}; - {error, Reason} -> - {error, Reason} - end. - -create(Title, Body, Author) -> - case pgo:query("INSERT INTO notes (title, body, author) VALUES ($1, $2, $3) " - "RETURNING id, title, body, author, inserted_at", - [Title, Body, Author]) of - #{rows := [Row]} -> - {ok, row_to_map(Row)}; - {error, Reason} -> - {error, Reason} - end. - -update(Id, Title, Body) -> - case pgo:query("UPDATE notes SET title = $1, body = $2, updated_at = NOW() WHERE id = $3 " - "RETURNING id, title, body, author, inserted_at", - [Title, Body, 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 notes 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, Title, Body, Author, InsertedAt}) -> - #{id => Id, - title => Title, - body => Body, - author => Author, - inserted_at => InsertedAt}. -``` - -## JSON API controller - -We can scaffold the API controller and a JSON schema in one step with the code generator: - -```shell -rebar3 nova gen_resource --name notes -===> Writing src/controllers/my_first_nova_notes_api_controller.erl -===> Writing priv/schemas/note.json -``` - -This gives us a controller with stub functions and a JSON schema that the [OpenAPI generator](../developer-tools/openapi.md) can pick up later. Now let's replace the stubs with our actual implementation. - -Create (or replace) `src/controllers/my_first_nova_notes_api_controller.erl`: - -```erlang --module(my_first_nova_notes_api_controller). --export([ - index/1, - show/1, - create/1, - update/1, - delete/1 - ]). - -index(_Req) -> - {ok, Notes} = my_first_nova_note_repo:all(), - {json, #{notes => Notes}}. - -show(#{bindings := #{<<"id">> := Id}}) -> - case my_first_nova_note_repo:get(binary_to_integer(Id)) of - {ok, Note} -> - {json, Note}; - {error, not_found} -> - {status, 404, #{}, #{error => <<"note not found">>}} - end. - -create(#{params := #{<<"title">> := Title, <<"body">> := Body, - <<"author">> := Author}}) -> - case my_first_nova_note_repo:create(Title, Body, Author) of - {ok, Note} -> - {json, 201, #{}, Note}; - {error, Reason} -> - {status, 422, #{}, #{error => list_to_binary(io_lib:format("~p", [Reason]))}} - end; -create(_Req) -> - {status, 422, #{}, #{error => <<"title, body and author required">>}}. - -update(#{bindings := #{<<"id">> := Id}, - params := #{<<"title">> := Title, <<"body">> := Body}}) -> - case my_first_nova_note_repo:update(binary_to_integer(Id), Title, Body) of - {ok, Note} -> - {json, Note}; - {error, not_found} -> - {status, 404, #{}, #{error => <<"note not found">>}} - end; -update(_Req) -> - {status, 422, #{}, #{error => <<"title and body required">>}}. - -delete(#{bindings := #{<<"id">> := Id}}) -> - case my_first_nova_note_repo:delete(binary_to_integer(Id)) of - ok -> - {status, 204}; - {error, not_found} -> - {status, 404, #{}, #{error => <<"note not found">>}} - end. -``` - -## HTML controller - -Create `src/controllers/my_first_nova_notes_controller.erl`: - -```erlang --module(my_first_nova_notes_controller). --export([ - index/1, - new/1, - create/1, - edit/1, - update/1, - delete/1 - ]). - -index(#{auth_data := #{username := Username}}) -> - {ok, Notes} = my_first_nova_note_repo:all(), - {ok, [{notes, Notes}, {username, Username}], #{view => notes_index}}; -index(_Req) -> - {redirect, "/login"}. - -new(#{auth_data := #{authed := true}}) -> - {ok, [], #{view => notes_new}}; -new(_Req) -> - {redirect, "/login"}. - -create(#{auth_data := #{username := Username}, - params := #{<<"title">> := Title, <<"body">> := Body}}) -> - my_first_nova_note_repo:create(Title, Body, Username), - {redirect, "/notes"}; -create(_Req) -> - {redirect, "/login"}. - -edit(#{auth_data := #{authed := true}, - bindings := #{<<"id">> := Id}}) -> - case my_first_nova_note_repo:get(binary_to_integer(Id)) of - {ok, Note} -> - {ok, [{note, Note}], #{view => notes_edit}}; - {error, not_found} -> - {status, 404} - end; -edit(_Req) -> - {redirect, "/login"}. - -update(#{auth_data := #{authed := true}, - bindings := #{<<"id">> := Id}, - params := #{<<"title">> := Title, <<"body">> := Body}}) -> - my_first_nova_note_repo:update(binary_to_integer(Id), Title, Body), - {redirect, "/notes"}; -update(_Req) -> - {redirect, "/login"}. - -delete(#{auth_data := #{authed := true}, - bindings := #{<<"id">> := Id}}) -> - my_first_nova_note_repo:delete(binary_to_integer(Id)), - {redirect, "/notes"}; -delete(_Req) -> - {redirect, "/login"}. -``` - -## Views - -Create the templates in `src/views/`. - -**`notes_index.dtl`** — List all notes: - -```html - -Notes - -

Notes

-

Welcome, {{ username }}

- New Note - - - -``` - -**`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).