rebar3_nova

rebar3 plugin for the Nova web framework. Scaffolds new Erlang web apps, generates controllers, resources and tests, lists routes, serves with auto-reload and builds releases.

Build

$ rebar3 compile

Installation

Add the plugin from Hex to your global rebar config (~/.config/rebar3/rebar.config):

{plugins, [
    {rebar3_nova, "~> 0.13"}
]}.

Or run the installer, which adds the same line and keeps the rest of the file as it is:

$ bash -c "$(curl -fsSL https://raw.githubusercontent.com/novaframework/rebar3_nova/v0.14.0/install.sh)"

This one-liner works once v0.14.0, the first release with this installer, is tagged; until then curl -f stops it with a 404, so add the plugin line above by hand.

The installer comes from a release tag, as does the install.escript it runs (REBAR3_NOVA_REF picks another tag). If rebar3 is missing it offers to install rebar3 3.27.0, downloaded into a private temporary directory and run only when its sha256 matches the one GitHub publishes for that release.

To track unreleased changes, use the git source instead:

{plugins, [
    {rebar3_nova, {git, "https://github.com/novaframework/rebar3_nova.git", {branch, "master"}}}
]}.

Then just call your plugin directly in an existing application:

$ rebar3 nova
===> Fetching rebar3_nova
===> Compiling rebar3_nova

Creating a project

$ rebar3 new nova my_app            # Nova + ErlyDTL views
$ rebar3 new nova_fullstack my_app  # Nova + Arizona live views + Kura/PostgreSQL

rebar3 nova new builds the same base project and adds parts with flags:

$ rebar3 nova new my_app --kura --arizona --docker
Flag Adds
--kura kura + kura_postgres, a repo module, the {kura, compile} migration hook, a {kura, [{repos, ...}]} config and a docker-compose.yml with PostgreSQL. Migrations are a release step, not run at boot (below).
--pgo pgo with a {pgo, [{pools, ...}]} config (not together with --kura)
--arizona arizona_nova, a live page at / with a layout, and the arizona_nova routes and middleware chain in the configs. Arizona needs OTP 29.
--otel OpenTelemetry deps and opentelemetry_nova:setup() at start
--lfe LFE sources instead of Erlang
--ci A GitHub Actions workflow
--docker A multi-stage Dockerfile (OTP 29 builder, Debian trixie runtime)

With --kura, and in nova_fullstack, the application does not migrate when it starts, so nodes that boot together do not race. Run the migrations once per deploy on the running node, bin/my_app eval 'kura_migrator:migrate(my_app_repo).', or call kura_migrator:migrate(my_app_repo) in rebar3 shell during development; the generated repo module says the same.

Releases keep distribution private by default: the node is named my_app@localhost and distribution is bound to 127.0.0.1. A development release (rebar3 release) uses config/dev_vm.args.src, which takes the cookie from ~/.erlang.cookie. A prod release (rebar3 as prod release, and the --docker image) uses config/vm.args.src, which takes it from the RELEASE_COOKIE environment variable, and requires it: a pre_start hook in the prod profile (rel/hooks/require_cookie, wired through extended_start_script_hooks) makes bin/my_app start, daemon, foreground and console exit with an error when it is unset or empty. Set it to the same secret on every node, e.g. export RELEASE_COOKIE="$(openssl rand -hex 32)" once, stored in your secret manager; ping, stop, remote_console and rpc read it too. For a cluster, also switch to -name my_app@<host> and drop the inet_dist_use_interface line. Keep -setcookie ${RELEASE_COOKIE} the last line of the file: an empty value there ends the arguments instead of taking the next one as the cookie. epmd, the port mapper, still listens on all interfaces (0.0.0.0:4369); set ERL_EPMD_ADDRESS=127.0.0.1 in the environment before the first node on the host starts it to keep it on loopback too. The --docker image sets it.

With --kura or --pgo, the prod config connects to PostgreSQL over TLS, verifying the server certificate against the OS trust store (for --pgo, /etc/ssl/certs/ca-certificates.crt and a DATABASE_HOST that is a host name); set DATABASE_SSL=false (DB_SSL in nova_fullstack) only for a database on the same host or a private network. The --docker image runs as an unprivileged app user that can write only log and the configs rendered into releases/<vsn> (the release itself stays owned by root), needs RELEASE_COOKIE at run time (docker run -e RELEASE_COOKIE="$(openssl rand -hex 32)" -p 8080:8080 <image>), its base images are pinned by digest, and a .dockerignore keeps _build, .git and .env out of the build context. The development docker-compose.yml publishes PostgreSQL on 127.0.0.1 only.

Generated projects target OTP 29 and rebar3 3.27 (.tool-versions), depend on {nova, "~> 0.18"} and list {rebar3_nova, "~> 0.13"} as a project plugin.

Commands

nova serve — Auto-reload development server

Starts the application with auto-reload. Source file changes are automatically compiled and hot-loaded. Editing the router re-registers its routes, so new routes work without a restart.

$ rebar3 nova serve

File watching uses the fs library. On Linux and the BSDs it needs inotifywait from inotify-tools (apt install inotify-tools, dnf install inotify-tools, pacman -S inotify-tools). Without it, serve prints a warning and runs without reloading.

The commands that read routes (routes, audit, openapi, doctor, gen_test) build the route table with the project's own Nova, which must be 0.18 or later.

nova routes — List compiled routes

Displays the compiled routing tree with methods, paths, and handler modules.

$ rebar3 nova routes

nova gen_controller — Generate a controller

Scaffolds a controller module with stub functions for CRUD actions.

$ rebar3 nova gen_controller --name users
===> Created src/controllers/myapp_users_controller.erl

$ rebar3 nova gen_controller --name users --actions list,show
===> Created src/controllers/myapp_users_controller.erl

Options:

Flag Required Default Description
--name, -n yes — Controller name
--actions, -a no list,show,create,update,delete Comma-separated actions to generate

Generated functions take the Nova request map and return stub responses:

The output is erlfmt-formatted, so rebar3 fmt --check passes right after generating.

If the file already exists, the command skips it with a warning.

nova gen_resource — Generate a full resource

Combines controller generation, JSON schema creation, and prints route snippets to add to your router.

$ rebar3 nova gen_resource --name products
===> Created src/controllers/myapp_products_controller.erl
===> Created priv/schemas/product.json
===>
===> Add these route groups to your router:
===>   #{prefix => "",
===>     routes => [
===>         {"/products", fun myapp_products_controller:list/1, #{methods => [get]}},
===>         {"/products/:id", fun myapp_products_controller:show/1, #{methods => [get]}}
===>     ]},
===>   #{prefix => "",
===>     %% Placeholders: replace require_bearer/1 with your security function
===>     %% and myapp_body_limit with a plugin that refuses large bodies. Until
===>     %% both exist, these routes fail closed.
===>     security => fun myapp_auth_security:require_bearer/1,
===>     %% Plugins run before security, so the group sets its own instead of
===>     %% inheriting a global body decoder that reads any size of body.
===>     plugins => [
===>         {pre_request, myapp_body_limit, #{}},
===>         {pre_request, nova_request_plugin, #{decode_json_body => true}},
===>         {pre_request, nova_json_schemas, #{}}
===>     ],
===>     routes => [
===>         {"/products", fun myapp_products_controller:create/1, #{methods => [post], extra_state => #{json_schema => "schemas/product.json"}}},
===>         {"/products/:id", fun myapp_products_controller:update/1, #{methods => [put], extra_state => #{json_schema => "schemas/product.json"}}},
===>         {"/products/:id", fun myapp_products_controller:delete/1, #{methods => [delete]}}
===>     ]}

Create, update and delete come in a group of their own behind a security function, so pasting the snippet does not make the writes public. Until you replace the placeholders with your own security function and body limit plugin, those routes fail closed. The group lists its own plugins, because Nova runs pre-request plugins before security: an inherited global body decoder would read a body of any size from a request without a token. A group's plugins replace the global ones, so nova_json_schemas is listed again when a route has a json_schema.

Options: Same as gen_controller.

The JSON schema is placed in priv/schemas/{singular}.json as a starting point: a draft-06 schema (the newest draft nova_json_schemas validates) with a required name. The resource name is naively singularized by stripping a trailing "s".

nova gen_test — Generate a Common Test suite

Scaffolds a CT suite with a test case per route of myapp_{name}_controller, using httpc.

$ rebar3 nova gen_test --name users
===> Created test/myapp_users_controller_SUITE.erl

Options:

Flag Required Default Description
--name, -n yes — Resource name

The paths, prefixes included, come from the compiled routes; with no routes for the controller yet, the suite assumes the gen_resource routes under /{name}. The generated suite starts inets and the application in init_per_suite, reads the port from the nova cowboy_configuration it runs with, uses 1 for path bindings and sends {"name": "test"} as the body, so it passes against the generated controller. Adapt the expectations as you implement the actions.

nova openapi — Generate OpenAPI 3.0.3 spec

Generates an OpenAPI 3.0.3 JSON specification from compiled controller routes and any JSON schemas found in priv/schemas/. Static files, error pages and inline funs are left out.

$ rebar3 nova openapi
===> OpenAPI spec written to /path/to/myapp/priv/assets/openapi.json

$ rebar3 nova openapi --prefix /api --output docs/openapi.json
$ rebar3 nova openapi --title "My API" --api-version 1.0.0

Options:

Flag Required Default Description
--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
--prefix, -p no (every route) Only document paths under this prefix, e.g. /api

Responses follow the method: 201 for POST, 204 for DELETE and 200 otherwise, plus 400 when the route has a request schema and 404 when the path has parameters. A route's extra_state => #{json_schema => "schemas/x.json"}, the key nova_json_schemas validates request bodies with, becomes the request body schema of POST, PUT and PATCH operations; it is not used to describe responses. Schemas are read from priv/schemas and its subdirectories and named by their relative path, so priv/schemas/v1/user.json is the component v1.user. $schema and $id are dropped from the component schemas, which OpenAPI 3.0 does not allow.

By default the spec goes to priv/assets, which the scaffold serves under /assets. It lists every route, so pass --output with a path outside priv/assets to keep it private. No Swagger UI page is written: earlier versions wrote a swagger.html that loaded Swagger UI from a CDN. Serve a self-hosted copy if you want one.

nova middleware — Show plugin/middleware chains

Displays global plugins and per-route-group plugin chains, preserving the grouping defined in your router's routes/1 function.

$ rebar3 nova middleware

=== Global Plugins ===
  (none)

=== Route Groups (myapp_router) ===

  Group: prefix=/  security=false
  Plugins:
    (inherits global: none)
  Routes:
    GET /heartbeat -> myapp_health_controller:heartbeat
    GET /health -> myapp_health_controller:health
    STATIC /assets/[...] -> assets
    WS /ws -> myapp_ws (websocket)
    STATUS 404 -> myapp_error_controller:not_found

  Group: prefix=/api  security=fun myapp_auth:validate_token/1
  Plugins:
    pre_request: nova_cors_plugin #{allow_origins => <<"*">>}
    pre_request: nova_request_logger #{level => info}
  Routes:
    GET /users -> myapp_users_controller:list
    POST /users -> myapp_users_controller:create

Groups that don't specify their own plugins key inherit from the global Nova plugin configuration.

nova config — Show Nova configuration

Displays the settings Nova reads from sys.config, grouped by the application env each one is read from, with current values or (default) when using built-in defaults. json_lib and use_sessions belong to the bootstrap application; a value set under nova instead is pointed out.

$ rebar3 nova config

=== Nova Configuration ===

  {nova, [...]}
    bootstrap_application     myapp
    environment               dev
    cowboy_configuration      #{port => 8080}
    plugins                   [] (default)
    use_stacktrace            false (default)
    render_error_pages        true (default)
    use_strict_routing        false (default)
    session_manager           nova_session_ets (default)
    dispatch_backend          persistent_term (default)

  {myapp, [...]}
    json_lib                  thoas (default)
    use_sessions              true (default)

Warns if bootstrap_application is not set.

nova audit — Audit route security

Checks compiled routes for common security issues and prints findings grouped by severity. The routes are those of the application and of the nova_apps declared for it in sys.config (nested ones included), compiled for one Nova environment, so routes that routes(prod) returns are audited with --env prod. The first line of the report says what was audited, what was not and which config nova_apps came from. nova_apps are read from the shell config unless --config names another; with an --env other than dev and no --config, the report notes that the dev config was used. A .config.src works: ${VAR} and ${VAR:-Default} are replaced from the environment or the default before it is read, as relx does. The audit sets the Nova environment and the router_modules from the config only while it compiles the routes.

audit and openapi read Nova's route records as nova 0.18.1 defines them. A route whose handler they do not recognise, as a later nova that changes those records would cause, is left out of the report and named in a warning; upgrade rebar3_nova when you see one.

$ rebar3 nova audit --env prod --config config/prod_sys.config.src

=== Security Audit ===

  Audited routes(prod) of myapp. Not covered: static files and error pages. nova_apps read from config/prod_sys.config.src.

  WARNINGS:
    POST /users (myapp_users_controller) has no security
    PUT /users/{id} (myapp_users_controller) has no security
    DELETE /users/{id} (myapp_users_controller) has no security

  INFO:
    GET /users (myapp_users_controller) has no security
    GET /health (myapp_health_controller) has no security

  Summary: 3 warning(s), 2 info(s)

Options:

Flag Required Default Description
--env, -e no dev Nova environment passed to the routers' routes/1
--config, -c no the shell config sys.config or .config.src to read nova_apps (and router_module) from
--strict, -s no false Fail when there are warnings, for CI

Rules checked:

Static file routes, error pages, websocket and other cowboy handlers, and routes served outside nova_router (such as arizona_nova routes) are not audited; the coverage line names them. Findings are sorted by path and method.

nova doctor — Diagnose project health

Runs a suite of read-only checks against the current project and reports findings grouped by section. Each finding has a level (ok, warning, or error). Ok findings are collapsed by default; pass -v to expand them.

$ rebar3 nova doctor

=== Nova Doctor ===

✅ Toolchain
    (3 ok, run with -v for detail)

✅ Project structure
    (3 ok, run with -v for detail)

⚠️ Configuration    1 warning(s)
    ! json_lib is set under nova
        hint: Nova reads json_lib from the bootstrap application; move it to {myapp, [...]}

✅ Routes
    (55 ok, run with -v for detail)

✅ Dependencies
    (2 ok, run with -v for detail)

⚠️ Security hygiene    1 warning(s)
    ! erl_crash.dump in project root
        hint: Delete it (already gitignored)

✅ Build artifacts
    (2 ok, run with -v for detail)

--
Summary: 67 ok, 2 warning(s), 0 error(s)

Sections:

Section Checks
toolchain OTP version, rebar3 version, .tool-versions or mise.toml pinning
project bootstrap_application set, the router (router_module or <app>_router) exports routes/1, .app.src lists nova in applications
config json_lib (read from the bootstrap app) is loaded and exports encode/1 and decode/1; {json_lib, json} is an error with a Nova whose request decoding expects {ok, Term}; json_lib set under nova; Cowboy port; session_manager, use_sessions, and the unused session_backend key
routes Every compiled route resolves to an exported handler (or captured fun); flags duplicate {method, path} pairs
deps rebar.lock committed, active profiles
security erl_crash.dump and .env presence vs .gitignore (asked of git check-ignore in a git repository; otherwise a whole .gitignore line, so .envrc does not count), and a literal -setcookie in config/vm.args(.src) (a warning, so projects from 0.13.4 and earlier still pass)
build _build/default and app ebin exist

Options:

Flag Required Default Description
--only no (all) Comma-separated section ids to run
--skip no (none) Comma-separated section ids to skip
-v, --verbose no false Expand ok-level findings, not just warnings/errors
-s, --strict no false Exit with code 1 if any warnings are reported (useful in CI)

Exit code is 0 when there are no errors. Errors always fail; warnings only fail under --strict.

$ rebar3 nova doctor --only routes
$ rebar3 nova doctor --skip toolchain,deps -v
$ rebar3 nova doctor --strict

nova release — Build a release

Runs rebar3 as <profile> release. If priv/schemas/ exists, regenerates the OpenAPI spec (with the openapi defaults) before building.

$ rebar3 nova release
===> Regenerating OpenAPI spec
===> Building release with profile 'prod'
===> Release built successfully with profile 'prod'

$ rebar3 nova release --profile staging

Options:

Flag Required Default Description
--profile, -p no prod Release profile to use