diff --git a/.github/workflows/erlang.yml b/.github/workflows/erlang.yml
index 0bfcf0f..ed35c24 100644
--- a/.github/workflows/erlang.yml
+++ b/.github/workflows/erlang.yml
@@ -5,7 +5,6 @@ on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-24.04
- name: OTP ${{matrix.otp}} / rebar3 ${{matrix.rebar3}}
strategy:
fail-fast: false
matrix:
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..c167877
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,52 @@
+# rebar3_nova
+
+Rebar3 plugin for scaffolding and managing Nova framework projects.
+
+## Build
+```bash
+rebar3 compile
+```
+
+## Commands provided
+```bash
+rebar3 new nova myapp # Create new Nova project
+rebar3 nova serve # Dev server with file watching + hot reload
+rebar3 nova routes # Display routing tree
+rebar3 nova gen_controller # Generate controller module
+rebar3 nova gen_resource # Generate controller + schema + route snippets
+rebar3 nova gen_test # Generate CT test suite
+rebar3 nova gen_auth # Generate email/password auth scaffolding
+rebar3 nova openapi # Generate OpenAPI spec to priv/assets/
+rebar3 nova middleware # Show plugin/middleware chains
+rebar3 nova config # Show Nova configuration
+rebar3 nova audit # Security audit of routes
+rebar3 nova release # Build release (regenerates OpenAPI if schemas exist)
+```
+
+## Key modules
+- `rebar3_nova.erl` — plugin registration, rebar3 version check
+- `rebar3_nova_prv.erl` — base provider (help)
+- `rebar3_nova_serve.erl` — dev server with inotify file watching, hot reload via `code:load_binary/3`
+- `rebar3_nova_routes.erl` — route tree display with Unicode box-drawing
+- `rebar3_nova_gen_controller.erl` — controller scaffolding
+- `rebar3_nova_gen_resource.erl` — full resource scaffolding (controller + schema + routes)
+- `rebar3_nova_gen_test.erl` — CT test suite generation
+- `rebar3_nova_gen_auth.erl` — email/password auth scaffolding (9 files)
+- `rebar3_nova_openapi.erl` — OpenAPI 3.0.3 spec + Swagger UI HTML (outputs to priv/assets/)
+- `rebar3_nova_middleware.erl` — middleware/plugin chain display
+- `rebar3_nova_config.erl` — Nova config display
+- `rebar3_nova_audit.erl` — route security audit
+- `rebar3_nova_release.erl` — release builder (wraps rebar3 release)
+- `rebar3_nova_utils.erl` — shared helpers (app name/dir, ensure_dir, file writing)
+
+## Templates
+- `priv/templates/nova/` — Erlang project scaffold
+- `priv/templates/nova_lfe/` — LFE project scaffold
+- `priv/templates/nova_plugin.template` — plugin module
+- `priv/templates/nova_websocket.template` — WebSocket module
+
+## Architecture
+Uses rebar3 provider pattern. Each command is a separate provider module in the `nova` namespace. All depend on `{default, compile}`.
+
+## Git workflow
+Default branch is `master`. NEVER push directly — always create a PR.
diff --git a/README.md b/README.md
index da5e178..db93067 100644
--- a/README.md
+++ b/README.md
@@ -69,22 +69,6 @@ Generated functions return stub responses:
If the file already exists, the command skips it with a warning.
-### `nova gen_router` — Generate a router module
-
-Scaffolds a router module implementing the `nova_router` behaviour with an empty routes list.
-
-```
-$ rebar3 nova gen_router --name api_v1 --prefix /api/v1
-===> Created src/myapp_api_v1_router.erl
-```
-
-**Options:**
-
-| Flag | Required | Default | Description |
-|------|----------|---------|-------------|
-| `--name`, `-n` | yes | — | Router name |
-| `--prefix`, `-p` | no | `""` | URL prefix for routes |
-
### `nova gen_resource` — Generate a full resource
Combines controller generation, JSON schema creation, and prints route snippets to add to your router.
@@ -129,21 +113,22 @@ Generates an OpenAPI 3.0.3 JSON specification from compiled routes and any JSON
```
$ rebar3 nova openapi
-===> OpenAPI spec written to openapi.json
-===> Swagger UI written to swagger.html
+===> OpenAPI spec written to /path/to/myapp/priv/assets/openapi.json
+===> Swagger UI written to /path/to/myapp/priv/assets/swagger.html
-$ rebar3 nova openapi --output priv/assets/openapi.json --title "My API" --api-version 1.0.0
+$ rebar3 nova openapi --title "My API" --api-version 1.0.0
+$ rebar3 nova openapi --output custom/path/openapi.json
```
**Options:**
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
-| `--output`, `-o` | no | `openapi.json` | Output file path |
+| `--output`, `-o` | no | `priv/assets/openapi.json` | Output file path |
| `--title`, `-t` | no | app name | API title |
| `--api-version`, `-v` | no | `0.1.0` | API version string |
-A `swagger.html` file is also generated alongside the spec for quick browser-based exploration.
+A `swagger.html` file is also generated alongside the spec for quick browser-based exploration. The output directory is created automatically if it doesn't exist.
### `nova middleware` — Show plugin/middleware chains
diff --git a/src/rebar3_nova.erl b/src/rebar3_nova.erl
index d322978..7d59829 100644
--- a/src/rebar3_nova.erl
+++ b/src/rebar3_nova.erl
@@ -10,16 +10,16 @@ init(State) ->
Minor > "15" ->
lists:foldl(fun provider_init/2, {ok, State},
[rebar3_nova_prv, rebar3_nova_serve, rebar3_nova_routes, rebar3_nova_openapi,
- rebar3_nova_gen_controller, rebar3_nova_gen_router, rebar3_nova_gen_resource,
- rebar3_nova_gen_test, rebar3_nova_middleware, rebar3_nova_config,
- rebar3_nova_audit, rebar3_nova_release]);
+ rebar3_nova_gen_controller, rebar3_nova_gen_resource,
+ rebar3_nova_gen_test, rebar3_nova_gen_auth, rebar3_nova_middleware,
+ rebar3_nova_config, rebar3_nova_audit, rebar3_nova_release]);
["git"] ->
rebar_api:info("Compiling with rebar3 from git - make sure you know what you are doing"),
lists:foldl(fun provider_init/2, {ok, State},
[rebar3_nova_prv, rebar3_nova_serve, rebar3_nova_routes, rebar3_nova_openapi,
- rebar3_nova_gen_controller, rebar3_nova_gen_router, rebar3_nova_gen_resource,
- rebar3_nova_gen_test, rebar3_nova_middleware, rebar3_nova_config,
- rebar3_nova_audit, rebar3_nova_release]);
+ rebar3_nova_gen_controller, rebar3_nova_gen_resource,
+ rebar3_nova_gen_test, rebar3_nova_gen_auth, rebar3_nova_middleware,
+ rebar3_nova_config, rebar3_nova_audit, rebar3_nova_release]);
SomethingElse ->
rebar_api:abort("Nova needs Rebar > 3.15 to function. Your version is: ~p. Please consider upgrading.", [SomethingElse])
end.
diff --git a/src/rebar3_nova_gen_auth.erl b/src/rebar3_nova_gen_auth.erl
new file mode 100644
index 0000000..473baf4
--- /dev/null
+++ b/src/rebar3_nova_gen_auth.erl
@@ -0,0 +1,690 @@
+-module(rebar3_nova_gen_auth).
+
+-export([init/1, do/1, format_error/1]).
+
+-define(PROVIDER, gen_auth).
+-define(DEPS, [{default, compile}]).
+
+-spec init(rebar_state:t()) -> {ok, rebar_state:t()}.
+init(State) ->
+ Provider = providers:create([
+ {name, ?PROVIDER},
+ {module, ?MODULE},
+ {namespace, nova},
+ {bare, true},
+ {deps, ?DEPS},
+ {example, "rebar3 nova gen_auth"},
+ {opts, []},
+ {short_desc, "Generate email/password authentication"},
+ {desc, "Generates schemas, controllers, and context modules for email/password auth"}
+ ]),
+ {ok, rebar_state:add_provider(State, Provider)}.
+
+-spec do(rebar_state:t()) -> {ok, rebar_state:t()} | {error, string()}.
+do(State) ->
+ AppName = rebar3_nova_utils:get_app_name(State),
+ AppDir = rebar3_nova_utils:get_app_dir(State),
+ App = atom_to_list(AppName),
+ generate_migration(AppDir),
+ generate_user_schema(App, AppDir),
+ generate_user_token_schema(App, AppDir),
+ generate_accounts(App, AppDir),
+ generate_auth(App, AppDir),
+ generate_session_controller(App, AppDir),
+ generate_registration_controller(App, AppDir),
+ generate_user_controller(App, AppDir),
+ generate_test_suite(App, AppDir),
+ print_instructions(App),
+ {ok, State}.
+
+-spec format_error(any()) -> iolist().
+format_error(Reason) ->
+ io_lib:format("~p", [Reason]).
+
+%%======================================================================
+%% Internal: timestamp for migration filename
+%%======================================================================
+
+timestamp() ->
+ {{Y, Mo, D}, {H, Mi, S}} = calendar:universal_time(),
+ lists:flatten(io_lib:format("~4..0B~2..0B~2..0B~2..0B~2..0B~2..0B",
+ [Y, Mo, D, H, Mi, S])).
+
+%%======================================================================
+%% Migration
+%%======================================================================
+
+generate_migration(AppDir) ->
+ TS = timestamp(),
+ Mod = "m" ++ TS ++ "_create_auth_tables",
+ FileName = filename:join([AppDir, "src", "migrations", Mod ++ ".erl"]),
+ Content = [
+ "-module(", Mod, ").\n"
+ "-behaviour(kura_migration).\n"
+ "-include_lib(\"kura/include/kura.hrl\").\n\n"
+ "-export([up/0, down/0]).\n\n"
+ "up() ->\n"
+ " [{execute, <<\"CREATE EXTENSION IF NOT EXISTS citext\">>},\n"
+ " {create_table, <<\"users\">>, [\n"
+ " #kura_column{name = id, type = id, primary_key = true},\n"
+ " #kura_column{name = email, type = string, nullable = false},\n"
+ " #kura_column{name = hashed_password, type = string, nullable = false},\n"
+ " #kura_column{name = confirmed_at, type = utc_datetime},\n"
+ " #kura_column{name = inserted_at, type = utc_datetime},\n"
+ " #kura_column{name = updated_at, type = utc_datetime}\n"
+ " ]},\n"
+ " {create_index, <<\"users_email_index\">>, <<\"users\">>, [email], [unique]},\n"
+ " {create_table, <<\"user_tokens\">>, [\n"
+ " #kura_column{name = id, type = id, primary_key = true},\n"
+ " #kura_column{name = user_id, type = integer, nullable = false},\n"
+ " #kura_column{name = token, type = string, nullable = false},\n"
+ " #kura_column{name = context, type = string, nullable = false},\n"
+ " #kura_column{name = inserted_at, type = utc_datetime}\n"
+ " ]},\n"
+ " {create_index, <<\"user_tokens_user_id_index\">>, <<\"user_tokens\">>, [user_id], []},\n"
+ " {create_index, <<\"user_tokens_context_token_index\">>, <<\"user_tokens\">>,\n"
+ " [context, token], [unique]},\n"
+ " {execute, <<\"ALTER TABLE user_tokens ADD CONSTRAINT user_tokens_user_id_fkey \"\n"
+ " \"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\">>}\n"
+ " ].\n\n"
+ "down() ->\n"
+ " [{drop_table, <<\"user_tokens\">>},\n"
+ " {drop_table, <<\"users\">>}].\n"
+ ],
+ rebar3_nova_utils:write_file_if_not_exists(FileName, Content).
+
+%%======================================================================
+%% User schema
+%%======================================================================
+
+generate_user_schema(App, AppDir) ->
+ Mod = App ++ "_user",
+ FileName = filename:join([AppDir, "src", "schemas", Mod ++ ".erl"]),
+ Content = [
+ "-module(", Mod, ").\n"
+ "-behaviour(kura_schema).\n"
+ "-include_lib(\"kura/include/kura.hrl\").\n\n"
+ "-export([table/0, fields/0, primary_key/0]).\n"
+ "-export([registration_changeset/2, password_changeset/2, email_changeset/2]).\n\n"
+ "table() -> <<\"users\">>.\n\n"
+ "primary_key() -> id.\n\n"
+ "fields() ->\n"
+ " [#kura_field{name = id, type = id, primary_key = true, nullable = false},\n"
+ " #kura_field{name = email, type = string, nullable = false},\n"
+ " #kura_field{name = hashed_password, type = string, nullable = false},\n"
+ " #kura_field{name = confirmed_at, type = utc_datetime},\n"
+ " #kura_field{name = inserted_at, type = utc_datetime},\n"
+ " #kura_field{name = updated_at, type = utc_datetime},\n"
+ " #kura_field{name = password, type = string, virtual = true},\n"
+ " #kura_field{name = password_confirmation, type = string, virtual = true}].\n\n"
+ "registration_changeset(Data, Params) ->\n"
+ " CS = kura_changeset:cast(", Mod, ", Data, Params,\n"
+ " [email, password, password_confirmation]),\n"
+ " CS1 = kura_changeset:validate_required(CS, [email, password, password_confirmation]),\n"
+ " CS2 = kura_changeset:validate_format(CS1, email, <<\"^[^@\\\\s]+@[^@\\\\s]+$\">>),\n"
+ " CS3 = kura_changeset:validate_length(CS2, email, [{max, 160}]),\n"
+ " CS4 = kura_changeset:validate_length(CS3, password, [{min, 12}, {max, 72}]),\n"
+ " CS5 = validate_password_confirmation(CS4),\n"
+ " CS6 = maybe_hash_password(CS5),\n"
+ " kura_changeset:unique_constraint(CS6, email).\n\n"
+ "password_changeset(Data, Params) ->\n"
+ " CS = kura_changeset:cast(", Mod, ", Data, Params,\n"
+ " [password, password_confirmation]),\n"
+ " CS1 = kura_changeset:validate_required(CS, [password, password_confirmation]),\n"
+ " CS2 = kura_changeset:validate_length(CS1, password, [{min, 12}, {max, 72}]),\n"
+ " CS3 = validate_password_confirmation(CS2),\n"
+ " maybe_hash_password(CS3).\n\n"
+ "email_changeset(Data, Params) ->\n"
+ " CS = kura_changeset:cast(", Mod, ", Data, Params, [email]),\n"
+ " CS1 = kura_changeset:validate_required(CS, [email]),\n"
+ " CS2 = kura_changeset:validate_format(CS1, email, <<\"^[^@\\\\s]+@[^@\\\\s]+$\">>),\n"
+ " CS3 = kura_changeset:validate_length(CS2, email, [{max, 160}]),\n"
+ " kura_changeset:unique_constraint(CS3, email).\n\n"
+ "%%----------------------------------------------------------------------\n"
+ "%% Internal\n"
+ "%%----------------------------------------------------------------------\n\n"
+ "validate_password_confirmation(CS) ->\n"
+ " case {kura_changeset:get_change(CS, password),\n"
+ " kura_changeset:get_change(CS, password_confirmation)} of\n"
+ " {Pass, Pass} when Pass =/= undefined -> CS;\n"
+ " {undefined, _} -> CS;\n"
+ " _ -> kura_changeset:add_error(CS, password_confirmation,\n"
+ " <<\"does not match password\">>)\n"
+ " end.\n\n"
+ "maybe_hash_password(#kura_changeset{valid = true} = CS) ->\n"
+ " case kura_changeset:get_change(CS, password) of\n"
+ " undefined -> CS;\n"
+ " Password ->\n"
+ " Hashed = list_to_binary(\n"
+ " bcrypt:hashpw(binary_to_list(Password), bcrypt:gen_salt())),\n"
+ " kura_changeset:put_change(CS, hashed_password, Hashed)\n"
+ " end;\n"
+ "maybe_hash_password(CS) ->\n"
+ " CS.\n"
+ ],
+ rebar3_nova_utils:write_file_if_not_exists(FileName, Content).
+
+%%======================================================================
+%% User token schema
+%%======================================================================
+
+generate_user_token_schema(App, AppDir) ->
+ Mod = App ++ "_user_token",
+ UserMod = App ++ "_user",
+ FileName = filename:join([AppDir, "src", "schemas", Mod ++ ".erl"]),
+ Content = [
+ "-module(", Mod, ").\n"
+ "-behaviour(kura_schema).\n"
+ "-include_lib(\"kura/include/kura.hrl\").\n\n"
+ "-export([table/0, fields/0, primary_key/0, associations/0]).\n\n"
+ "table() -> <<\"user_tokens\">>.\n\n"
+ "primary_key() -> id.\n\n"
+ "fields() ->\n"
+ " [#kura_field{name = id, type = id, primary_key = true, nullable = false},\n"
+ " #kura_field{name = user_id, type = integer, nullable = false},\n"
+ " #kura_field{name = token, type = string, nullable = false},\n"
+ " #kura_field{name = context, type = string, nullable = false},\n"
+ " #kura_field{name = inserted_at, type = utc_datetime}].\n\n"
+ "associations() ->\n"
+ " [#kura_assoc{name = user, type = belongs_to, schema = ", UserMod, ",\n"
+ " foreign_key = user_id}].\n"
+ ],
+ rebar3_nova_utils:write_file_if_not_exists(FileName, Content).
+
+%%======================================================================
+%% Accounts context
+%%======================================================================
+
+generate_accounts(App, AppDir) ->
+ Mod = App ++ "_accounts",
+ Repo = App ++ "_repo",
+ UserMod = App ++ "_user",
+ TokenMod = App ++ "_user_token",
+ FileName = filename:join([AppDir, "src", Mod ++ ".erl"]),
+ Content = [
+ "-module(", Mod, ").\n"
+ "-include_lib(\"kura/include/kura.hrl\").\n\n"
+ "-export([\n"
+ " register_user/1,\n"
+ " get_user_by_email_and_password/2,\n"
+ " get_user_by_id/1,\n"
+ " generate_session_token/1,\n"
+ " get_user_by_session_token/1,\n"
+ " delete_session_token/1,\n"
+ " delete_all_user_tokens/1,\n"
+ " change_user_password/3,\n"
+ " change_user_email/3,\n"
+ " user_to_json/1,\n"
+ " format_errors/1\n"
+ "]).\n\n"
+ "-define(SESSION_VALIDITY_DAYS, 14).\n\n"
+ "%%----------------------------------------------------------------------\n"
+ "%% Registration\n"
+ "%%----------------------------------------------------------------------\n\n"
+ "register_user(Params) ->\n"
+ " Now = calendar:universal_time(),\n"
+ " CS = ", UserMod, ":registration_changeset(#{}, Params),\n"
+ " CS1 = kura_changeset:put_change(CS, inserted_at, Now),\n"
+ " CS2 = kura_changeset:put_change(CS1, updated_at, Now),\n"
+ " ", Repo, ":insert(CS2).\n\n"
+ "%%----------------------------------------------------------------------\n"
+ "%% Authentication\n"
+ "%%----------------------------------------------------------------------\n\n"
+ "get_user_by_email_and_password(Email, Password) ->\n"
+ " Q = kura_query:where(kura_query:from(", UserMod, "), {email, Email}),\n"
+ " case ", Repo, ":all(Q) of\n"
+ " {ok, [User]} ->\n"
+ " case verify_password(Password, maps:get(hashed_password, User)) of\n"
+ " true -> {ok, User};\n"
+ " false -> {error, invalid_credentials}\n"
+ " end;\n"
+ " _ ->\n"
+ " dummy_verify(),\n"
+ " {error, invalid_credentials}\n"
+ " end.\n\n"
+ "get_user_by_id(Id) ->\n"
+ " case ", Repo, ":get(", UserMod, ", Id) of\n"
+ " {ok, User} -> {ok, User};\n"
+ " _ -> {error, not_found}\n"
+ " end.\n\n"
+ "%%----------------------------------------------------------------------\n"
+ "%% Session tokens\n"
+ "%%----------------------------------------------------------------------\n\n"
+ "generate_session_token(User) ->\n"
+ " Raw = crypto:strong_rand_bytes(32),\n"
+ " SessionToken = base64:encode(Raw),\n"
+ " HashedToken = base64:encode(crypto:hash(sha256, Raw)),\n"
+ " Now = calendar:universal_time(),\n"
+ " CS = kura_changeset:cast(", TokenMod, ", #{}, #{user_id => maps:get(id, User),\n"
+ " token => HashedToken, context => <<\"session\">>,\n"
+ " inserted_at => Now}, [user_id, token, context, inserted_at]),\n"
+ " case ", Repo, ":insert(CS) of\n"
+ " {ok, _} -> {ok, SessionToken};\n"
+ " {error, _} = Err -> Err\n"
+ " end.\n\n"
+ "get_user_by_session_token(SessionToken) ->\n"
+ " try\n"
+ " Raw = base64:decode(SessionToken),\n"
+ " HashedToken = base64:encode(crypto:hash(sha256, Raw)),\n"
+ " Q = kura_query:where(\n"
+ " kura_query:where(kura_query:from(", TokenMod, "),\n"
+ " {token, HashedToken}),\n"
+ " {context, <<\"session\">>}),\n"
+ " case ", Repo, ":all(Q) of\n"
+ " {ok, [Token]} ->\n"
+ " case token_valid(maps:get(inserted_at, Token)) of\n"
+ " true -> get_user_by_id(maps:get(user_id, Token));\n"
+ " false -> {error, token_expired}\n"
+ " end;\n"
+ " _ -> {error, not_found}\n"
+ " end\n"
+ " catch\n"
+ " _:_ -> {error, invalid_token}\n"
+ " end.\n\n"
+ "delete_session_token(SessionToken) ->\n"
+ " try\n"
+ " Raw = base64:decode(SessionToken),\n"
+ " HashedToken = base64:encode(crypto:hash(sha256, Raw)),\n"
+ " Q = kura_query:where(\n"
+ " kura_query:where(kura_query:from(", TokenMod, "),\n"
+ " {token, HashedToken}),\n"
+ " {context, <<\"session\">>}),\n"
+ " ", Repo, ":delete_all(Q),\n"
+ " ok\n"
+ " catch\n"
+ " _:_ -> ok\n"
+ " end.\n\n"
+ "delete_all_user_tokens(UserId) ->\n"
+ " Q = kura_query:where(kura_query:from(", TokenMod, "), {user_id, UserId}),\n"
+ " ", Repo, ":delete_all(Q),\n"
+ " ok.\n\n"
+ "%%----------------------------------------------------------------------\n"
+ "%% Password & email changes\n"
+ "%%----------------------------------------------------------------------\n\n"
+ "change_user_password(User, CurrentPassword, NewParams) ->\n"
+ " case verify_password(CurrentPassword, maps:get(hashed_password, User)) of\n"
+ " true ->\n"
+ " Now = calendar:universal_time(),\n"
+ " CS = ", UserMod, ":password_changeset(User, NewParams),\n"
+ " CS1 = kura_changeset:put_change(CS, updated_at, Now),\n"
+ " case ", Repo, ":update(CS1) of\n"
+ " {ok, UpdatedUser} ->\n"
+ " delete_all_user_tokens(maps:get(id, User)),\n"
+ " {ok, UpdatedUser};\n"
+ " {error, _} = Err -> Err\n"
+ " end;\n"
+ " false ->\n"
+ " {error, invalid_password}\n"
+ " end.\n\n"
+ "change_user_email(User, CurrentPassword, NewParams) ->\n"
+ " case verify_password(CurrentPassword, maps:get(hashed_password, User)) of\n"
+ " true ->\n"
+ " Now = calendar:universal_time(),\n"
+ " CS = ", UserMod, ":email_changeset(User, NewParams),\n"
+ " CS1 = kura_changeset:put_change(CS, updated_at, Now),\n"
+ " case ", Repo, ":update(CS1) of\n"
+ " {ok, UpdatedUser} ->\n"
+ " delete_all_user_tokens(maps:get(id, User)),\n"
+ " {ok, UpdatedUser};\n"
+ " {error, _} = Err -> Err\n"
+ " end;\n"
+ " false ->\n"
+ " {error, invalid_password}\n"
+ " end.\n\n"
+ "%%----------------------------------------------------------------------\n"
+ "%% JSON helpers\n"
+ "%%----------------------------------------------------------------------\n\n"
+ "user_to_json(User) ->\n"
+ " #{<<\"id\">> => maps:get(id, User),\n"
+ " <<\"email\">> => maps:get(email, User)}.\n\n"
+ "format_errors(#kura_changeset{errors = Errors}) ->\n"
+ " maps:from_list([{atom_to_binary(F), M} || {F, M} <- Errors]).\n\n"
+ "%%----------------------------------------------------------------------\n"
+ "%% Internal\n"
+ "%%----------------------------------------------------------------------\n\n"
+ "verify_password(Password, HashedPassword) ->\n"
+ " Hash = list_to_binary(\n"
+ " bcrypt:hashpw(binary_to_list(Password), binary_to_list(HashedPassword))),\n"
+ " crypto:hash_equals(Hash, HashedPassword).\n\n"
+ "dummy_verify() ->\n"
+ " bcrypt:hashpw(\"dummy\", bcrypt:gen_salt()),\n"
+ " false.\n\n"
+ "token_valid(InsertedAt) ->\n"
+ " Now = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),\n"
+ " TokenTime = calendar:datetime_to_gregorian_seconds(InsertedAt),\n"
+ " (Now - TokenTime) < (?SESSION_VALIDITY_DAYS * 24 * 60 * 60).\n"
+ ],
+ rebar3_nova_utils:write_file_if_not_exists(FileName, Content).
+
+%%======================================================================
+%% Auth security module
+%%======================================================================
+
+generate_auth(App, AppDir) ->
+ Mod = App ++ "_auth",
+ Accounts = App ++ "_accounts",
+ FileName = filename:join([AppDir, "src", Mod ++ ".erl"]),
+ Content = [
+ "-module(", Mod, ").\n\n"
+ "-export([require_authenticated/1]).\n\n"
+ "require_authenticated(Req) ->\n"
+ " case nova_session:get(Req, <<\"session_token\">>) of\n"
+ " {ok, Token} ->\n"
+ " case ", Accounts, ":get_user_by_session_token(Token) of\n"
+ " {ok, User} ->\n"
+ " {true, User};\n"
+ " _ ->\n"
+ " unauthorized()\n"
+ " end;\n"
+ " _ ->\n"
+ " unauthorized()\n"
+ " end.\n\n"
+ "unauthorized() ->\n"
+ " Body = thoas:encode(#{<<\"error\">> => <<\"unauthorized\">>}),\n"
+ " {false, 401, #{<<\"content-type\">> => <<\"application/json\">>}, Body}.\n"
+ ],
+ rebar3_nova_utils:write_file_if_not_exists(FileName, Content).
+
+%%======================================================================
+%% Session controller
+%%======================================================================
+
+generate_session_controller(App, AppDir) ->
+ Mod = App ++ "_session_controller",
+ Accounts = App ++ "_accounts",
+ FileName = filename:join([AppDir, "src", "controllers", Mod ++ ".erl"]),
+ Content = [
+ "-module(", Mod, ").\n\n"
+ "-export([create/1, delete/1]).\n\n"
+ "create(Req) ->\n"
+ " #{<<\"email\">> := Email, <<\"password\">> := Password} = maps:get(json, Req),\n"
+ " case ", Accounts, ":get_user_by_email_and_password(Email, Password) of\n"
+ " {ok, User} ->\n"
+ " {ok, Token} = ", Accounts, ":generate_session_token(User),\n"
+ " ok = nova_session:set(Req, <<\"session_token\">>, Token),\n"
+ " {json, #{<<\"user\">> => ", Accounts, ":user_to_json(User)}};\n"
+ " {error, _} ->\n"
+ " {json, 401, #{}, #{<<\"error\">> => <<\"invalid email or password\">>}}\n"
+ " end.\n\n"
+ "delete(Req) ->\n"
+ " case nova_session:get(Req, <<\"session_token\">>) of\n"
+ " {ok, Token} ->\n"
+ " ", Accounts, ":delete_session_token(Token);\n"
+ " _ ->\n"
+ " ok\n"
+ " end,\n"
+ " nova_session:delete(Req, <<\"session_token\">>),\n"
+ " {status, 204}.\n"
+ ],
+ rebar3_nova_utils:write_file_if_not_exists(FileName, Content).
+
+%%======================================================================
+%% Registration controller
+%%======================================================================
+
+generate_registration_controller(App, AppDir) ->
+ Mod = App ++ "_registration_controller",
+ Accounts = App ++ "_accounts",
+ FileName = filename:join([AppDir, "src", "controllers", Mod ++ ".erl"]),
+ Content = [
+ "-module(", Mod, ").\n\n"
+ "-export([create/1]).\n\n"
+ "create(Req) ->\n"
+ " Params = maps:get(json, Req),\n"
+ " case ", Accounts, ":register_user(Params) of\n"
+ " {ok, User} ->\n"
+ " {ok, Token} = ", Accounts, ":generate_session_token(User),\n"
+ " ok = nova_session:set(Req, <<\"session_token\">>, Token),\n"
+ " {json, 201, #{}, #{<<\"user\">> => ", Accounts, ":user_to_json(User)}};\n"
+ " {error, CS} ->\n"
+ " {json, 422, #{}, #{<<\"errors\">> => ", Accounts, ":format_errors(CS)}}\n"
+ " end.\n"
+ ],
+ rebar3_nova_utils:write_file_if_not_exists(FileName, Content).
+
+%%======================================================================
+%% User controller
+%%======================================================================
+
+generate_user_controller(App, AppDir) ->
+ Mod = App ++ "_user_controller",
+ Accounts = App ++ "_accounts",
+ FileName = filename:join([AppDir, "src", "controllers", Mod ++ ".erl"]),
+ Content = [
+ "-module(", Mod, ").\n\n"
+ "-export([show/1, update_password/1, update_email/1]).\n\n"
+ "show(Req) ->\n"
+ " User = maps:get(auth_data, Req),\n"
+ " {json, #{<<\"user\">> => ", Accounts, ":user_to_json(User)}}.\n\n"
+ "update_password(Req) ->\n"
+ " User = maps:get(auth_data, Req),\n"
+ " #{<<\"current_password\">> := CurrentPassword} = maps:get(json, Req),\n"
+ " NewParams = maps:get(json, Req),\n"
+ " case ", Accounts, ":change_user_password(User, CurrentPassword, NewParams) of\n"
+ " {ok, UpdatedUser} ->\n"
+ " {ok, Token} = ", Accounts, ":generate_session_token(UpdatedUser),\n"
+ " ok = nova_session:set(Req, <<\"session_token\">>, Token),\n"
+ " {json, #{<<\"user\">> => ", Accounts, ":user_to_json(UpdatedUser)}};\n"
+ " {error, invalid_password} ->\n"
+ " {json, 401, #{}, #{<<\"error\">> => <<\"invalid current password\">>}};\n"
+ " {error, CS} ->\n"
+ " {json, 422, #{}, #{<<\"errors\">> => ", Accounts, ":format_errors(CS)}}\n"
+ " end.\n\n"
+ "update_email(Req) ->\n"
+ " User = maps:get(auth_data, Req),\n"
+ " #{<<\"current_password\">> := CurrentPassword} = maps:get(json, Req),\n"
+ " NewParams = maps:get(json, Req),\n"
+ " case ", Accounts, ":change_user_email(User, CurrentPassword, NewParams) of\n"
+ " {ok, UpdatedUser} ->\n"
+ " {ok, Token} = ", Accounts, ":generate_session_token(UpdatedUser),\n"
+ " ok = nova_session:set(Req, <<\"session_token\">>, Token),\n"
+ " {json, #{<<\"user\">> => ", Accounts, ":user_to_json(UpdatedUser)}};\n"
+ " {error, invalid_password} ->\n"
+ " {json, 401, #{}, #{<<\"error\">> => <<\"invalid current password\">>}};\n"
+ " {error, CS} ->\n"
+ " {json, 422, #{}, #{<<\"errors\">> => ", Accounts, ":format_errors(CS)}}\n"
+ " end.\n"
+ ],
+ rebar3_nova_utils:write_file_if_not_exists(FileName, Content).
+
+%%======================================================================
+%% Test suite
+%%======================================================================
+
+generate_test_suite(App, AppDir) ->
+ Suite = App ++ "_auth_SUITE",
+ FileName = filename:join([AppDir, "test", Suite ++ ".erl"]),
+ Content = [
+ "-module(", Suite, ").\n"
+ "-include_lib(\"common_test/include/ct.hrl\").\n\n"
+ "-export([all/0, init_per_suite/1, end_per_suite/1,\n"
+ " init_per_testcase/2, end_per_testcase/2]).\n"
+ "-export([\n"
+ " test_register/1,\n"
+ " test_register_invalid/1,\n"
+ " test_login/1,\n"
+ " test_login_invalid/1,\n"
+ " test_logout/1,\n"
+ " test_get_current_user/1,\n"
+ " test_unauthorized/1,\n"
+ " test_update_password/1,\n"
+ " test_update_email/1\n"
+ "]).\n\n"
+ "-define(BASE_URL, \"http://localhost:8080\").\n\n"
+ "all() ->\n"
+ " [test_register, test_register_invalid, test_login, test_login_invalid,\n"
+ " test_logout, test_get_current_user, test_unauthorized,\n"
+ " test_update_password, test_update_email].\n\n"
+ "init_per_suite(Config) ->\n"
+ " application:ensure_all_started(inets),\n"
+ " application:ensure_all_started(ssl),\n"
+ " application:ensure_all_started(", App, "),\n"
+ " Config.\n\n"
+ "end_per_suite(_Config) ->\n"
+ " application:stop(", App, "),\n"
+ " ok.\n\n"
+ "init_per_testcase(_TestCase, Config) ->\n"
+ " Config.\n\n"
+ "end_per_testcase(_TestCase, _Config) ->\n"
+ " ok.\n\n"
+ "%%----------------------------------------------------------------------\n"
+ "%% Registration\n"
+ "%%----------------------------------------------------------------------\n\n"
+ "test_register(_Config) ->\n"
+ " Body = encode(#{<<\"email\">> => <<\"register@example.com\">>,\n"
+ " <<\"password\">> => <<\"password123456\">>,\n"
+ " <<\"password_confirmation\">> => <<\"password123456\">>}),\n"
+ " {ok, {{_, 201, _}, _, RespBody}} =\n"
+ " httpc:request(post,\n"
+ " {?BASE_URL ++ \"/api/register\", [], \"application/json\", Body},\n"
+ " [], []),\n"
+ " #{<<\"user\">> := #{<<\"id\">> := _, <<\"email\">> := <<\"register@example.com\">>}} =\n"
+ " decode(RespBody).\n\n"
+ "test_register_invalid(_Config) ->\n"
+ " %% Missing password\n"
+ " Body1 = encode(#{<<\"email\">> => <<\"invalid@example.com\">>}),\n"
+ " {ok, {{_, 422, _}, _, _}} =\n"
+ " httpc:request(post,\n"
+ " {?BASE_URL ++ \"/api/register\", [], \"application/json\", Body1},\n"
+ " [], []),\n"
+ " %% Short password\n"
+ " Body2 = encode(#{<<\"email\">> => <<\"invalid@example.com\">>,\n"
+ " <<\"password\">> => <<\"short\">>,\n"
+ " <<\"password_confirmation\">> => <<\"short\">>}),\n"
+ " {ok, {{_, 422, _}, _, _}} =\n"
+ " httpc:request(post,\n"
+ " {?BASE_URL ++ \"/api/register\", [], \"application/json\", Body2},\n"
+ " [], []).\n\n"
+ "%%----------------------------------------------------------------------\n"
+ "%% Login / Logout\n"
+ "%%----------------------------------------------------------------------\n\n"
+ "test_login(_Config) ->\n"
+ " register_user(<<\"login@example.com\">>, <<\"password123456\">>),\n"
+ " Body = encode(#{<<\"email\">> => <<\"login@example.com\">>,\n"
+ " <<\"password\">> => <<\"password123456\">>}),\n"
+ " {ok, {{_, 200, _}, _, RespBody}} =\n"
+ " httpc:request(post,\n"
+ " {?BASE_URL ++ \"/api/login\", [], \"application/json\", Body},\n"
+ " [], []),\n"
+ " #{<<\"user\">> := #{<<\"email\">> := <<\"login@example.com\">>}} =\n"
+ " decode(RespBody).\n\n"
+ "test_login_invalid(_Config) ->\n"
+ " Body = encode(#{<<\"email\">> => <<\"nobody@example.com\">>,\n"
+ " <<\"password\">> => <<\"wrongpassword1\">>}),\n"
+ " {ok, {{_, 401, _}, _, _}} =\n"
+ " httpc:request(post,\n"
+ " {?BASE_URL ++ \"/api/login\", [], \"application/json\", Body},\n"
+ " [], []).\n\n"
+ "test_logout(_Config) ->\n"
+ " Cookie = register_and_login(<<\"logout@example.com\">>, <<\"password123456\">>),\n"
+ " {ok, {{_, 204, _}, _, _}} =\n"
+ " httpc:request(delete,\n"
+ " {?BASE_URL ++ \"/api/logout\", [{\"Cookie\", Cookie}]},\n"
+ " [], []).\n\n"
+ "%%----------------------------------------------------------------------\n"
+ "%% Current user\n"
+ "%%----------------------------------------------------------------------\n\n"
+ "test_get_current_user(_Config) ->\n"
+ " Cookie = register_and_login(<<\"me@example.com\">>, <<\"password123456\">>),\n"
+ " {ok, {{_, 200, _}, _, RespBody}} =\n"
+ " httpc:request(get,\n"
+ " {?BASE_URL ++ \"/api/me\", [{\"Cookie\", Cookie}]},\n"
+ " [], []),\n"
+ " #{<<\"user\">> := #{<<\"email\">> := <<\"me@example.com\">>}} =\n"
+ " decode(RespBody).\n\n"
+ "test_unauthorized(_Config) ->\n"
+ " {ok, {{_, 401, _}, _, _}} =\n"
+ " httpc:request(get, {?BASE_URL ++ \"/api/me\", []}, [], []).\n\n"
+ "%%----------------------------------------------------------------------\n"
+ "%% Password & email update\n"
+ "%%----------------------------------------------------------------------\n\n"
+ "test_update_password(_Config) ->\n"
+ " Cookie = register_and_login(<<\"pwchange@example.com\">>, <<\"password123456\">>),\n"
+ " Body = encode(#{<<\"current_password\">> => <<\"password123456\">>,\n"
+ " <<\"password\">> => <<\"newpassword12345\">>,\n"
+ " <<\"password_confirmation\">> => <<\"newpassword12345\">>}),\n"
+ " {ok, {{_, 200, _}, _, _}} =\n"
+ " httpc:request(put,\n"
+ " {?BASE_URL ++ \"/api/me/password\",\n"
+ " [{\"Cookie\", Cookie}], \"application/json\", Body},\n"
+ " [], []).\n\n"
+ "test_update_email(_Config) ->\n"
+ " Cookie = register_and_login(<<\"emailchange@example.com\">>, <<\"password123456\">>),\n"
+ " Body = encode(#{<<\"current_password\">> => <<\"password123456\">>,\n"
+ " <<\"email\">> => <<\"newemail@example.com\">>}),\n"
+ " {ok, {{_, 200, _}, _, _}} =\n"
+ " httpc:request(put,\n"
+ " {?BASE_URL ++ \"/api/me/email\",\n"
+ " [{\"Cookie\", Cookie}], \"application/json\", Body},\n"
+ " [], []).\n\n"
+ "%%----------------------------------------------------------------------\n"
+ "%% Helpers\n"
+ "%%----------------------------------------------------------------------\n\n"
+ "register_user(Email, Password) ->\n"
+ " Body = encode(#{<<\"email\">> => Email, <<\"password\">> => Password,\n"
+ " <<\"password_confirmation\">> => Password}),\n"
+ " {ok, {{_, 201, _}, _, _}} =\n"
+ " httpc:request(post,\n"
+ " {?BASE_URL ++ \"/api/register\", [], \"application/json\", Body},\n"
+ " [], []).\n\n"
+ "register_and_login(Email, Password) ->\n"
+ " Body = encode(#{<<\"email\">> => Email, <<\"password\">> => Password,\n"
+ " <<\"password_confirmation\">> => Password}),\n"
+ " {ok, {{_, 201, _}, Headers, _}} =\n"
+ " httpc:request(post,\n"
+ " {?BASE_URL ++ \"/api/register\", [], \"application/json\", Body},\n"
+ " [], []),\n"
+ " extract_cookie(Headers).\n\n"
+ "extract_cookie(Headers) ->\n"
+ " case lists:keyfind(\"set-cookie\", 1, Headers) of\n"
+ " {_, Cookie} -> Cookie;\n"
+ " false -> \"\"\n"
+ " end.\n\n"
+ "encode(Map) ->\n"
+ " binary_to_list(thoas:encode(Map)).\n\n"
+ "decode(Body) ->\n"
+ " {ok, Json} = thoas:decode(list_to_binary(Body)),\n"
+ " Json.\n"
+ ],
+ rebar3_nova_utils:write_file_if_not_exists(FileName, Content).
+
+%%======================================================================
+%% Print instructions
+%%======================================================================
+
+print_instructions(App) ->
+ AuthMod = App ++ "_auth",
+ SessionCtrl = App ++ "_session_controller",
+ RegCtrl = App ++ "_registration_controller",
+ UserCtrl = App ++ "_user_controller",
+ rebar_api:info("~n==> Authentication files generated successfully!~n", []),
+ rebar_api:info("Next steps:~n", []),
+ rebar_api:info("1. Add kura and bcrypt to your deps in rebar.config:~n", []),
+ rebar_api:info(" {deps, [..., kura, bcrypt]}.~n~n", []),
+ rebar_api:info("2. Add these routes to your router:~n", []),
+ rebar_api:info(" %% Public routes~n", []),
+ rebar_api:info(" #{prefix => <<\"/api\">>,~n"
+ " security => false,~n"
+ " plugins => [{pre_request, nova_request_plugin,~n"
+ " #{decode_json_body => true}}],~n"
+ " routes => [~n"
+ " {<<\"/register\">>, fun ~s:create/1, #{methods => [post]}},~n"
+ " {<<\"/login\">>, fun ~s:create/1, #{methods => [post]}}~n"
+ " ]}~n", [RegCtrl, SessionCtrl]),
+ rebar_api:info(" %% Protected routes~n", []),
+ rebar_api:info(" #{prefix => <<\"/api\">>,~n"
+ " security => fun ~s:require_authenticated/1,~n"
+ " plugins => [{pre_request, nova_request_plugin,~n"
+ " #{decode_json_body => true}}],~n"
+ " routes => [~n"
+ " {<<\"/logout\">>, fun ~s:delete/1, #{methods => [delete]}},~n"
+ " {<<\"/me\">>, fun ~s:show/1, #{methods => [get]}},~n"
+ " {<<\"/me/password\">>, fun ~s:update_password/1, #{methods => [put]}},~n"
+ " {<<\"/me/email\">>, fun ~s:update_email/1, #{methods => [put]}}~n"
+ " ]}~n", [AuthMod, SessionCtrl, UserCtrl, UserCtrl, UserCtrl]),
+ rebar_api:info("3. Add src/schemas and src/migrations to src_dirs in rebar.config:~n", []),
+ rebar_api:info(" {src_dirs, [\"src\", \"src/controllers\", \"src/schemas\", \"src/migrations\"]}.~n~n", []),
+ rebar_api:info("4. Run the migration:~n", []),
+ rebar_api:info(" rebar3 kura migrate~n~n", []),
+ rebar_api:info("5. Ensure nova_request_plugin with decode_json_body is configured~n"
+ " (either globally in sys.config or per route group as shown above).~n", []).
diff --git a/src/rebar3_nova_gen_router.erl b/src/rebar3_nova_gen_router.erl
deleted file mode 100644
index 8d8210b..0000000
--- a/src/rebar3_nova_gen_router.erl
+++ /dev/null
@@ -1,59 +0,0 @@
--module(rebar3_nova_gen_router).
-
--export([init/1, do/1, format_error/1]).
-
--define(PROVIDER, gen_router).
--define(DEPS, [{default, compile}]).
-
--spec init(rebar_state:t()) -> {ok, rebar_state:t()}.
-init(State) ->
- Provider = providers:create([
- {name, ?PROVIDER},
- {module, ?MODULE},
- {namespace, nova},
- {bare, true},
- {deps, ?DEPS},
- {example, "rebar3 nova gen_router --name api_v1 --prefix /api/v1"},
- {opts, [
- {name, $n, "name", string, "Router name (required)"},
- {prefix, $p, "prefix", {string, ""}, "URL prefix for routes"}
- ]},
- {short_desc, "Generate a Nova router module"},
- {desc, "Generates a router module implementing the nova_router behaviour"}
- ]),
- {ok, rebar_state:add_provider(State, Provider)}.
-
--spec do(rebar_state:t()) -> {ok, rebar_state:t()} | {error, string()}.
-do(State) ->
- {Args, _} = rebar_state:command_parsed_args(State),
- case proplists:get_value(name, Args) of
- undefined ->
- rebar_api:abort("--name is required", []);
- Name ->
- AppName = rebar3_nova_utils:get_app_name(State),
- AppDir = rebar3_nova_utils:get_app_dir(State),
- Prefix = proplists:get_value(prefix, Args, ""),
- generate(AppName, AppDir, Name, Prefix),
- {ok, State}
- end.
-
--spec format_error(any()) -> iolist().
-format_error(Reason) ->
- io_lib:format("~p", [Reason]).
-
-generate(AppName, AppDir, Name, Prefix) ->
- ModName = io_lib:format("~s_~s_router", [AppName, Name]),
- FileName = filename:join([AppDir, "src", lists:flatten(ModName) ++ ".erl"]),
- Content = generate_content(lists:flatten(ModName), Prefix),
- rebar3_nova_utils:write_file_if_not_exists(FileName, Content).
-
-generate_content(ModName, Prefix) ->
- iolist_to_binary(io_lib:format(
- "-module(~s).~n"
- "-behaviour(nova_router).~n~n"
- "-export([routes/0, prefix/0]).~n~n"
- "prefix() ->~n"
- " \"~s\".~n~n"
- "routes() ->~n"
- " [].~n",
- [ModName, Prefix])).
diff --git a/src/rebar3_nova_openapi.erl b/src/rebar3_nova_openapi.erl
index 8e38ea0..ab59de3 100644
--- a/src/rebar3_nova_openapi.erl
+++ b/src/rebar3_nova_openapi.erl
@@ -21,7 +21,7 @@ init(State) ->
{deps, ?DEPS},
{example, "rebar3 nova openapi"},
{opts, [
- {output, $o, "output", {string, "openapi.json"}, "Output file path"},
+ {output, $o, "output", string, "Output file path (default: priv/assets/openapi.json)"},
{title, $t, "title", string, "API title (default: app name)"},
{api_version, $v, "api-version", {string, "0.1.0"}, "API version"}
]},
@@ -37,7 +37,10 @@ do(State) ->
AppDir = rebar_app_info:dir(Hd),
{Args, _} = rebar_state:command_parsed_args(State),
- Output = proplists:get_value(output, Args, "openapi.json"),
+ Output = case proplists:get_value(output, Args) of
+ undefined -> filename:join([AppDir, "priv", "assets", "openapi.json"]);
+ O -> O
+ end,
Title = case proplists:get_value(title, Args) of
undefined -> erlang:atom_to_list(AppName);
T -> T
@@ -52,6 +55,7 @@ do(State) ->
Spec = build_spec(Title, ApiVersion, Routes, Schemas),
Json = thoas:encode(Spec),
+ ok = filelib:ensure_dir(Output),
ok = file:write_file(Output, Json),
rebar_api:info("OpenAPI spec written to ~s", [Output]),
@@ -224,7 +228,7 @@ swagger_html(SpecPath) ->
" \n"
"