Quick reference for Nova's APIs, return values, configuration, and Kura's database layer.
| Return | Description |
{ok, Variables} | Render the default template with variables |
{ok, Variables, #{view => Name}} | Render a specific template |
{ok, Variables, #{view => Name, status_code => Code}} | Render template with custom status |
{json, Data} | JSON response (status 200) |
{json, StatusCode, Headers, Body} | JSON response with custom status and headers |
{status, StatusCode} | Bare status code response |
{status, StatusCode, Headers, Body} | Status with headers and body |
{redirect, Path} | HTTP redirect |
{sendfile, StatusCode, Headers, {Offset, Length, Path}, MimeType} | Send a file |
#{
prefix => "/api", %% Path prefix (string)
security => false | fun Module:Function/1, %% Security function
plugins => [{Phase, Module, Options}], %% Per-route plugins (optional)
routes => [
{Path, fun Module:Function/1, #{methods => [get, post, put, delete]}},
{Path, WebSocketModule, #{protocol => ws}}, %% WebSocket route
{StatusCode, fun Module:Function/1, #{}} %% Error handler
]
}
{"/users/:id", fun my_controller:show/1, #{methods => [get]}}
%% Access in controller:
show(#{bindings := #{<<"id">> := Id}}) -> ...
%% Return {true, AuthData} to allow, false to deny
my_security(#{params := Params}) ->
case check_credentials(Params) of
ok -> {true, #{user => <<"alice">>}};
_ -> false
end.
%% AuthData is available in the controller as auth_data
index(#{auth_data := #{user := User}}) -> ...
-behaviour(nova_plugin).
pre_request(Req, Env, Options, State) ->
{ok, Req, State} | %% Continue
{break, Req, State} | %% Skip remaining plugins
{stop, Req, State} | %% Stop — plugin sent response
{error, Reason}. %% 500 error
post_request(Req, Env, Options, State) ->
%% Same return values as pre_request.
{ok, Req, State}.
plugin_info() ->
#{title := binary(), version := binary(), url := binary(),
authors := [binary()], description := binary(),
options => [{atom(), binary()}]}.
%% Global (sys.config)
{plugins, [
{pre_request, Module, Options},
{post_request, Module, Options}
]}
%% Per-route (in router)
#{plugins => [{pre_request, Module, Options}],
routes => [...]}
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}
Req1 = cowboy_req:set_resp_cookie(<<"session_id">>, SessionId, Req, #{
path => <<"/">>,
http_only => true,
secure => true,
max_age => 86400
}).
-behaviour(nova_websocket).
init(State) ->
{ok, State}. %% Accept connection
websocket_handle({text, Msg}, State) ->
{ok, State} | %% Do nothing
{reply, {text, Response}, State} | %% Send message
{stop, State}. %% Close connection
websocket_info(ErlangMsg, State) ->
%% Same return values as websocket_handle
{"/ws", my_ws_handler, #{protocol => ws}}
nova_pubsub:join(Channel)
nova_pubsub:leave(Channel)
nova_pubsub:broadcast(Channel, Topic, Payload)
nova_pubsub:local_broadcast(Channel, Topic, Payload)
nova_pubsub:get_members(Channel)
nova_pubsub:get_local_members(Channel)
%% Message format received by processes:
{nova_pubsub, Channel, SenderPid, Topic, Payload}
{pre_request, nova_request_plugin, #{
decode_json_body => true, %% Decode JSON body into `json` key
read_urlencoded_body => true, %% Decode URL-encoded form data into `params` key
parse_qs => true %% Parse query string into `parsed_qs` key
}}
{nova, [
{environment, dev | prod},
{bootstrap_application, my_app},
{dev_mode, true | false},
{use_stacktrace, true | false},
{session_manager, nova_session_ets},
{render_error_pages, true | false},
{cowboy_configuration, #{
port => 8080,
use_ssl => false,
ssl_port => 8443,
ssl_options => #{certfile => "...", keyfile => "..."},
stream_handlers => [cowboy_stream_h]
}},
{plugins, [...]}
]}
{my_app, [
{nova_apps, [
{nova_admin, #{prefix => "/admin"}},
{other_app, #{prefix => "/other"}}
]}
]}
-module(my_schema).
-behaviour(kura_schema).
-include_lib("kura/include/kura.hrl").
-export([table/0, fields/0, associations/0, embeds/0]).
table() -> <<"my_table">>.
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}].
| 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 | TIMESTAMPTZ | {{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 |
%% 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}
Types = #{email => string, age => integer},
CS = kura_changeset:cast(Types, #{}, Params, [email, age]).
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]}]).
%% 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}
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]}}})
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).
| Command | Description |
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 |
rebar3 eunit | Run EUnit tests |
rebar3 ct | Run Common Test suites |
rebar3 do eunit, ct | Run both |
rebar3 as prod release | Build production release |
rebar3 as prod tar | Build release tarball |
rebar3 dialyzer | Run type checker |
| Command | Description |
rebar3 nova gen_controller --name NAME | Generate a controller with stub actions |
rebar3 nova gen_resource --name NAME | Generate controller + JSON schema + route hints |
rebar3 nova gen_test --name NAME | Generate a Common Test suite |
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 release | Build release with auto-generated OpenAPI |
| 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 |
# Controller with specific actions
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
# Kura setup with custom repo name
rebar3 kura setup --name my_repo
-compile({parse_transform, arizona_parse_transform}).
-behaviour(arizona_view).
mount(Params, Req) ->
arizona_view:new(?MODULE, #{
id => ~"my_view",
title => <<"Hello">>
}, none).
render(Bindings) ->
arizona_template:from_html(~"""
<h1>{arizona_template:get_binding(title, Bindings)}</h1>
<button az-click="my_event">Click</button>
""").
handle_event(EventName, Params, View) ->
State = arizona_view:get_state(View),
NewState = arizona_stateful:put_binding(key, value, State),
{Actions, arizona_view:update_state(NewState, View)}.
handle_info(ErlangMessage, View) ->
{Actions, UpdatedView}.
%% Read state from view
State = arizona_view:get_state(View),
Value = arizona_stateful:get_binding(key, State),
%% Update state
NewState = arizona_stateful:put_binding(key, NewValue, State),
UpdatedView = arizona_view:update_state(NewState, View).
| Attribute | Triggers on |
az-click | Click |
az-submit | Form submission |
az-change | Input change |
az-keydown | Key press |
az-keyup | Key release |
az-focus | Element focus |
az-blur | Element blur |
az-value-* | Pass data with events |
az-debounce | Delay event (ms) |
<a href="/path" az-live-redirect>Navigate (new view)</a>
<a href="/path?q=x" az-live-patch>Navigate (same view, new params)</a>
{[{redirect, "/path"}], View}
{[{patch, "/path?page=2"}], View}
{[{dispatch, EventName, Payload}], View}
%% Stateless — pure function (no behaviour)
my_component(Bindings) ->
arizona_template:from_html(~"<h2>{maps:get(title, Bindings)}</h2>").
%% Render stateless in template
arizona_template:render_stateless(my_module, my_component, #{title => ~"Hi"})
%% Stateful — behaviour with mount/render/handle_event
-behaviour(arizona_stateful).
mount(Bindings) ->
arizona_stateful:new(?MODULE, #{id => maps:get(id, Bindings), ...}).
%% Render stateful in template (must have unique id)
arizona_template:render_stateful(my_component, #{id => ~"my-id", ...})
arizona.pushEvent("event", {key: "value"})
arizona.pushEventTo("#component-id", "event", {})
await arizona.callEvent("event", {})
await arizona.callEventFrom("#id", "event", {})
-module(my_mailer).
-behaviour(hikyaku_mailer).
-export([config/0]).
config() ->
#{adapter => hikyaku_adapter_smtp,
relay => <<"smtp.example.com">>,
port => 587,
username => <<"user">>,
password => <<"pass">>,
tls => always}.
E0 = hikyaku_email:new(),
E1 = hikyaku_email:from(E0, {<<"Name">>, <<"addr@example.com">>}),
E2 = hikyaku_email:to(E1, <<"recipient@example.com">>),
E3 = hikyaku_email:cc(E2, <<"cc@example.com">>),
E4 = hikyaku_email:bcc(E3, <<"bcc@example.com">>),
E5 = hikyaku_email:reply_to(E4, <<"reply@example.com">>),
E6 = hikyaku_email:subject(E5, <<"Subject line">>),
E7 = hikyaku_email:text_body(E6, <<"Plain text body">>),
E8 = hikyaku_email:html_body(E7, <<"<h1>HTML body</h1>">>),
E9 = hikyaku_email:header(E8, <<"X-Custom">>, <<"value">>),
{ok, _} = hikyaku_mailer:deliver(my_mailer, E9).
Att = hikyaku_attachment:from_data(Data, <<"file.pdf">>),
E1 = hikyaku_email:attachment(E0, Att).
%% Inline attachment with Content-ID
Att2 = hikyaku_attachment:from_data(ImgData, <<"logo.png">>),
Att3 = hikyaku_attachment:inline(Att2, <<"logo">>),
E2 = hikyaku_email:attachment(E1, Att3).
| Adapter | Config keys |
hikyaku_adapter_smtp | relay, port, username, password, tls |
hikyaku_adapter_sendgrid | api_key |
hikyaku_adapter_mailgun | api_key, domain |
hikyaku_adapter_ses | access_key, secret_key, region |
hikyaku_adapter_logger | level |
hikyaku_adapter_test | pid |