From 784bf4504e3b1e243642d591237a95d369f0ffe2 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Sat, 14 Feb 2026 19:11:05 +0100 Subject: [PATCH 1/2] feat: add kura + PostgreSQL persistence to my_first_nova example Replace hardcoded in-memory data in products and users controllers with real PostgreSQL persistence using kura schemas, changesets, and repo. Adds docker-compose for local PG, auto-generated migrations, nova_test integration tests (CT), and meck-based unit tests (eunit). Co-Authored-By: Claude Opus 4.6 --- .../my_first_nova/config/dev_sys.config.src | 5 +- examples/my_first_nova/config/test_sys.config | 16 +++ examples/my_first_nova/docker-compose.yml | 14 +++ .../my_first_nova/priv/schemas/product.json | 11 -- examples/my_first_nova/priv/schemas/user.json | 10 -- examples/my_first_nova/rebar.config | 19 ++- examples/my_first_nova/rebar.lock | 47 ++++++++ .../my_first_nova_api_controller.erl | 32 +++-- .../my_first_nova_products_controller.erl | 70 +++++++---- .../m20260214180502_update_schema.erl | 25 ++++ .../my_first_nova/src/my_first_nova.app.src | 3 +- .../my_first_nova/src/my_first_nova_app.erl | 2 + .../my_first_nova/src/my_first_nova_repo.erl | 51 ++++++++ .../my_first_nova/src/schemas/product.erl | 18 +++ examples/my_first_nova/src/schemas/user.erl | 17 +++ .../test/my_first_nova_api_SUITE.erl | 103 ++++++++++++++++ .../my_first_nova_api_controller_tests.erl | 62 ++++++++-- ...y_first_nova_products_controller_tests.erl | 113 +++++++++++++++--- 18 files changed, 523 insertions(+), 95 deletions(-) create mode 100644 examples/my_first_nova/config/test_sys.config create mode 100644 examples/my_first_nova/docker-compose.yml delete mode 100644 examples/my_first_nova/priv/schemas/product.json delete mode 100644 examples/my_first_nova/priv/schemas/user.json create mode 100644 examples/my_first_nova/rebar.lock create mode 100644 examples/my_first_nova/src/migrations/m20260214180502_update_schema.erl create mode 100644 examples/my_first_nova/src/my_first_nova_repo.erl create mode 100644 examples/my_first_nova/src/schemas/product.erl create mode 100644 examples/my_first_nova/src/schemas/user.erl create mode 100644 examples/my_first_nova/test/my_first_nova_api_SUITE.erl diff --git a/examples/my_first_nova/config/dev_sys.config.src b/examples/my_first_nova/config/dev_sys.config.src index e087808..a5cd456 100644 --- a/examples/my_first_nova/config/dev_sys.config.src +++ b/examples/my_first_nova/config/dev_sys.config.src @@ -29,5 +29,8 @@ read_urlencoded_body => true}} ]} ]} - %% Please change your app.src-file instead if you intend to add app-specific configurations + }, + {my_first_nova, [ + {database, <<"my_first_nova_dev">>} + ]} ]. diff --git a/examples/my_first_nova/config/test_sys.config b/examples/my_first_nova/config/test_sys.config new file mode 100644 index 0000000..78036e4 --- /dev/null +++ b/examples/my_first_nova/config/test_sys.config @@ -0,0 +1,16 @@ +[ + {nova, [ + {environment, test}, + {cowboy_configuration, #{ + port => 8099 + }}, + {bootstrap_application, my_first_nova}, + {plugins, [ + {pre_request, nova_request_plugin, #{decode_json_body => true, + read_urlencoded_body => true}} + ]} + ]}, + {my_first_nova, [ + {database, <<"my_first_nova_dev">>} + ]} +]. diff --git a/examples/my_first_nova/docker-compose.yml b/examples/my_first_nova/docker-compose.yml new file mode 100644 index 0000000..0c2bceb --- /dev/null +++ b/examples/my_first_nova/docker-compose.yml @@ -0,0 +1,14 @@ +services: + postgres: + image: postgres:16 + ports: + - "5432:5432" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: my_first_nova_dev + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: diff --git a/examples/my_first_nova/priv/schemas/product.json b/examples/my_first_nova/priv/schemas/product.json deleted file mode 100644 index 81ea870..0000000 --- a/examples/my_first_nova/priv/schemas/product.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$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" }, - "description": { "type": "string", "description": "Product description" } - }, - "required": ["name", "price"] -} diff --git a/examples/my_first_nova/priv/schemas/user.json b/examples/my_first_nova/priv/schemas/user.json deleted file mode 100644 index 33bccca..0000000 --- a/examples/my_first_nova/priv/schemas/user.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$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"] -} diff --git a/examples/my_first_nova/rebar.config b/examples/my_first_nova/rebar.config index 166e2d4..c21ecdb 100644 --- a/examples/my_first_nova/rebar.config +++ b/examples/my_first_nova/rebar.config @@ -1,7 +1,7 @@ %% -*- mode: erlang;erlang-indent-level: 4;indent-tabs-mode: nil -*- {erl_opts, [debug_info]}. -{src_dirs, ["src", "src/controllers"]}. +{src_dirs, [{"src", [{recursive, true}]}]}. {shell, [{config, "./config/dev_sys.config.src"}]}. {erlydtl_opts, [{doc_root, "src/views"}, @@ -14,7 +14,8 @@ {deps, [ nova, - {flatlog, "0.1.2"} %% Used for logging - Change if needed + {flatlog, "0.1.2"}, %% Used for logging - Change if needed + {kura, "~> 0.3"} ]}. @@ -31,18 +32,24 @@ {profiles, [{prod, [{relx, [{mode, prod}, - {sys_config_src, "./config/prod_sys.config.src"}]}]}]}. + {sys_config_src, "./config/prod_sys.config.src"}]}]}, + {test, [{deps, [meck, nova_test]}, + {ct_opts, [{sys_config, ["config/test_sys.config"]}]}]}]}. %% Plugins for rebar3 {plugins, [ {rebar3_erlydtl_plugin, ".*", {git, "https://github.com/tsloughter/rebar3_erlydtl_plugin.git", {branch, "master"}}}, - {rebar3_erldb_plugin, ".*", - {git, "https://github.com/erldb/rebar3_erldb_plugin.git", {branch, "master"}}}, {rebar3_nova, ".*", {git, "https://github.com/novaframework/rebar3_nova.git", {branch, "master"}}} ]}. +{project_plugins, [ + {rebar3_kura, "~> 0.3"}, + erlfmt +]}. + {provider_hooks, [ - {pre, [{compile, {erlydtl, compile}}]} + {pre, [{compile, {erlydtl, compile}}, + {compile, {kura, compile}}]} ]}. diff --git a/examples/my_first_nova/rebar.lock b/examples/my_first_nova/rebar.lock new file mode 100644 index 0000000..6ab7704 --- /dev/null +++ b/examples/my_first_nova/rebar.lock @@ -0,0 +1,47 @@ +{"1.2.0", +[{<<"backoff">>,{pkg,<<"backoff">>,<<"1.1.6">>},2}, + {<<"cowboy">>,{pkg,<<"cowboy">>,<<"2.13.0">>},1}, + {<<"cowlib">>,{pkg,<<"cowlib">>,<<"2.16.0">>},2}, + {<<"erlydtl">>,{pkg,<<"erlydtl">>,<<"0.14.0">>},1}, + {<<"flatlog">>,{pkg,<<"flatlog">>,<<"0.1.2">>},0}, + {<<"jhn_stdlib">>,{pkg,<<"jhn_stdlib">>,<<"5.4.0">>},1}, + {<<"kura">>,{pkg,<<"kura">>,<<"0.3.0">>},0}, + {<<"nova">>,{pkg,<<"nova">>,<<"0.13.1">>},0}, + {<<"opentelemetry_api">>,{pkg,<<"opentelemetry_api">>,<<"1.5.0">>},2}, + {<<"pg_types">>,{pkg,<<"pg_types">>,<<"0.6.0">>},2}, + {<<"pgo">>,{pkg,<<"pgo">>,<<"0.20.0">>},1}, + {<<"ranch">>,{pkg,<<"ranch">>,<<"2.2.0">>},2}, + {<<"routing_tree">>,{pkg,<<"routing_tree">>,<<"1.0.11">>},1}, + {<<"thoas">>,{pkg,<<"thoas">>,<<"1.2.1">>},1}]}. +[ +{pkg_hash,[ + {<<"backoff">>, <<"83B72ED2108BA1EE8F7D1C22E0B4A00CFE3593A67DBC792799E8CCE9F42F796B">>}, + {<<"cowboy">>, <<"09D770DD5F6A22CC60C071F432CD7CB87776164527F205C5A6B0F24FF6B38990">>}, + {<<"cowlib">>, <<"54592074EBBBB92EE4746C8A8846E5605052F29309D3A873468D76CDF932076F">>}, + {<<"erlydtl">>, <<"964B2DC84F8C17ACFAA69C59BA129EF26AC45D2BA898C3C6AD9B5BDC8BA13CED">>}, + {<<"flatlog">>, <<"8C4B81A4931A1396254DBD975B841F4A6350D6F128FF94FFE86799A4451E32B1">>}, + {<<"jhn_stdlib">>, <<"FAC6F19B35351278F1CB156E23A5B2A6047A9DD5AB1FD9E1189A7918006DF7ED">>}, + {<<"kura">>, <<"4B0391AF6B6CB8E010B6A88BF88BB6E420379C6FE453B5E627E9C320916F9717">>}, + {<<"nova">>, <<"6A2F3E9D69FB12AA10CA530F53049A4A25005F8E816587B150CB86BEDDAF85DB">>}, + {<<"opentelemetry_api">>, <<"1A676F3E3340CAB81C763E939A42E11A70C22863F645AA06AAFEFC689B5550CF">>}, + {<<"pg_types">>, <<"B530C56330A59288BE49CDD27739EC025B6E877A475AE70BF95B04F1E3A817C4">>}, + {<<"pgo">>, <<"4F4A1FCB0A4894311BE238195BDAD4DF80312AB091DB87FE5348FAFA4DA75F87">>}, + {<<"ranch">>, <<"25528F82BC8D7C6152C57666CA99EC716510FE0925CB188172F41CE93117B1B0">>}, + {<<"routing_tree">>, <<"72ACEF2095F0EC804F7AFD07EF781DDE5009425A1CA0A28F0706B1DB334A4812">>}, + {<<"thoas">>, <<"19A25F31177A17E74004D4840F66D791D4298C5738790FA2CC73731EB911F195">>}]}, +{pkg_hash_ext,[ + {<<"backoff">>, <<"CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39">>}, + {<<"cowboy">>, <<"E724D3A70995025D654C1992C7B11DBFEA95205C047D86FF9BF1CDA92DDC5614">>}, + {<<"cowlib">>, <<"7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51">>}, + {<<"erlydtl">>, <<"D80EC044CD8F58809C19D29AC5605BE09E955040911B644505E31E9DD8143431">>}, + {<<"flatlog">>, <<"FDD2A311A67F63F9D0BC194FAD6BEAF9CCCDE8FFFEE2919DF1C4D86098E49984">>}, + {<<"jhn_stdlib">>, <<"7EABD1B01D2DEFF495BF7C5CA1DBA4D3FA0B84DC3AF03CA85F31D52EBB03C6FC">>}, + {<<"kura">>, <<"63BF8E6A0C7430DE6BBB8FA97F5E918FCA5DEE936D080EE52CB753A908131344">>}, + {<<"nova">>, <<"B6B0B66DFEBD427944C9AFB2E1F9046F707E4F647AB7F1884CBDE3FEA84396DC">>}, + {<<"opentelemetry_api">>, <<"F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA">>}, + {<<"pg_types">>, <<"9949A4849DD13408FA249AB7B745E0D2DFDB9532AEE2B9722326E33CD082A778">>}, + {<<"pgo">>, <<"2F11E6649CEB38E569EF56B16BE1D04874AE5B11A02867080A2817CE423C683B">>}, + {<<"ranch">>, <<"FA0B99A1780C80218A4197A59EA8D3BDAE32FBFF7E88527D7D8A4787EFF4F8E7">>}, + {<<"routing_tree">>, <<"85982C7AC502892C5179CD2A591331003BACD2D2A71723640BA7D23F45408E6E">>}, + {<<"thoas">>, <<"E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A">>}]} +]. diff --git a/examples/my_first_nova/src/controllers/my_first_nova_api_controller.erl b/examples/my_first_nova/src/controllers/my_first_nova_api_controller.erl index 3a4c3d3..5d5f147 100644 --- a/examples/my_first_nova/src/controllers/my_first_nova_api_controller.erl +++ b/examples/my_first_nova/src/controllers/my_first_nova_api_controller.erl @@ -1,23 +1,33 @@ -module(my_first_nova_api_controller). +-include_lib("kura/include/kura.hrl"). -export([ - index/1, - show/1, - create/1 - ]). + 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">>} - ], + {ok, Users} = my_first_nova_repo:all(kura_query:from(user)), {json, #{users => Users}}. show(#{bindings := #{<<"id">> := Id}}) -> - {json, #{id => binary_to_integer(Id), name => <<"Alice">>, email => <<"alice@example.com">>}}; + case my_first_nova_repo:get(user, binary_to_integer(Id)) of + {ok, User} -> + {json, User}; + {error, not_found} -> + {status, 404, #{}, #{error => <<"not found">>}} + end; show(_Req) -> {status, 400, #{}, #{error => <<"missing id">>}}. -create(#{json := #{<<"name">> := Name, <<"email">> := Email}}) -> - {json, 201, #{}, #{id => 3, name => Name, email => Email}}; +create(#{json := Params}) -> + CS = kura_changeset:cast(user, #{}, Params, [name, email]), + CS1 = kura_changeset:validate_required(CS, [name, email]), + case my_first_nova_repo:insert(CS1) of + {ok, User} -> + {json, 201, #{}, User}; + {error, Changeset} -> + {status, 422, #{}, #{errors => Changeset#kura_changeset.errors}} + end; create(_Req) -> {status, 422, #{}, #{error => <<"name and email required">>}}. diff --git a/examples/my_first_nova/src/controllers/my_first_nova_products_controller.erl b/examples/my_first_nova/src/controllers/my_first_nova_products_controller.erl index ee0194e..8347f78 100644 --- a/examples/my_first_nova/src/controllers/my_first_nova_products_controller.erl +++ b/examples/my_first_nova/src/controllers/my_first_nova_products_controller.erl @@ -1,41 +1,63 @@ -module(my_first_nova_products_controller). +-include_lib("kura/include/kura.hrl"). -export([ - list/1, - show/1, - create/1, - update/1, - delete/1 - ]). + list/1, + show/1, + create/1, + update/1, + delete/1 +]). list(_Req) -> - Products = [ - #{id => 1, name => <<"Widget">>, price => 999, description => <<"A fine widget">>}, - #{id => 2, name => <<"Gadget">>, price => 1999, description => <<"A fancy gadget">>} - ], + {ok, Products} = my_first_nova_repo:all(kura_query:from(product)), {json, #{products => Products}}. show(#{bindings := #{<<"id">> := Id}}) -> - {json, #{id => binary_to_integer(Id), - name => <<"Widget">>, - price => 999, - description => <<"A fine widget">>}}; + case my_first_nova_repo:get(product, binary_to_integer(Id)) of + {ok, Product} -> + {json, Product}; + {error, not_found} -> + {status, 404, #{}, #{error => <<"not found">>}} + end; show(_Req) -> {status, 400, #{}, #{error => <<"missing id">>}}. -create(#{params := #{<<"name">> := Name, <<"price">> := Price}} = Req) -> - Desc = maps:get(<<"description">>, maps:get(params, Req, #{}), <<>>), - {json, 201, #{}, #{id => 3, name => Name, price => Price, description => Desc}}; +create(#{json := Params}) -> + CS = kura_changeset:cast(product, #{}, Params, [name, price, description]), + CS1 = kura_changeset:validate_required(CS, [name, price]), + case my_first_nova_repo:insert(CS1) of + {ok, Product} -> + {json, 201, #{}, Product}; + {error, Changeset} -> + {status, 422, #{}, #{errors => Changeset#kura_changeset.errors}} + end; create(_Req) -> {status, 422, #{}, #{error => <<"name and price required">>}}. -update(#{bindings := #{<<"id">> := Id}, - params := #{<<"name">> := Name, <<"price">> := Price}} = Req) -> - Desc = maps:get(<<"description">>, maps:get(params, Req, #{}), <<>>), - {json, #{id => binary_to_integer(Id), name => Name, price => Price, description => Desc}}; +update(#{bindings := #{<<"id">> := Id}, json := Params}) -> + case my_first_nova_repo:get(product, binary_to_integer(Id)) of + {ok, Existing} -> + CS = kura_changeset:cast(product, Existing, Params, [name, price, description]), + case my_first_nova_repo:update(CS) of + {ok, Updated} -> + {json, Updated}; + {error, Changeset} -> + {status, 422, #{}, #{errors => Changeset#kura_changeset.errors}} + end; + {error, not_found} -> + {status, 404, #{}, #{error => <<"not found">>}} + end; update(_Req) -> - {status, 422, #{}, #{error => <<"name and price required">>}}. + {status, 422, #{}, #{error => <<"invalid request">>}}. -delete(#{bindings := #{<<"id">> := _Id}}) -> - {status, 204}; +delete(#{bindings := #{<<"id">> := Id}}) -> + case my_first_nova_repo:get(product, binary_to_integer(Id)) of + {ok, Record} -> + CS = kura_changeset:cast(product, Record, #{}, []), + {ok, _} = my_first_nova_repo:delete(CS), + {status, 204}; + {error, not_found} -> + {status, 404, #{}, #{error => <<"not found">>}} + end; delete(_Req) -> {status, 400, #{}, #{error => <<"missing id">>}}. diff --git a/examples/my_first_nova/src/migrations/m20260214180502_update_schema.erl b/examples/my_first_nova/src/migrations/m20260214180502_update_schema.erl new file mode 100644 index 0000000..34fa1d9 --- /dev/null +++ b/examples/my_first_nova/src/migrations/m20260214180502_update_schema.erl @@ -0,0 +1,25 @@ +-module(m20260214180502_update_schema). +-behaviour(kura_migration). +-include_lib("kura/include/kura.hrl"). +-export([up/0, down/0]). + +up() -> + [{create_table, <<"products">>, [ + #kura_column{name = id, type = id, primary_key = true, nullable = false}, + #kura_column{name = name, type = string, nullable = false}, + #kura_column{name = price, type = integer, nullable = false}, + #kura_column{name = description, type = text}, + #kura_column{name = inserted_at, type = utc_datetime}, + #kura_column{name = updated_at, type = utc_datetime} + ]}, + {create_table, <<"users">>, [ + #kura_column{name = id, type = id, primary_key = true, nullable = false}, + #kura_column{name = name, type = string, nullable = false}, + #kura_column{name = email, type = string, nullable = false}, + #kura_column{name = inserted_at, type = utc_datetime}, + #kura_column{name = updated_at, type = utc_datetime} + ]}]. + +down() -> + [{drop_table, <<"products">>}, + {drop_table, <<"users">>}]. diff --git a/examples/my_first_nova/src/my_first_nova.app.src b/examples/my_first_nova/src/my_first_nova.app.src index 2a05a9c..22bc64a 100644 --- a/examples/my_first_nova/src/my_first_nova.app.src +++ b/examples/my_first_nova/src/my_first_nova.app.src @@ -9,7 +9,8 @@ [ kernel, stdlib, - nova + nova, + pgo ]}, {env,[]}, {modules, []}, diff --git a/examples/my_first_nova/src/my_first_nova_app.erl b/examples/my_first_nova/src/my_first_nova_app.erl index 35795ed..45aedec 100644 --- a/examples/my_first_nova/src/my_first_nova_app.erl +++ b/examples/my_first_nova/src/my_first_nova_app.erl @@ -15,6 +15,8 @@ %%==================================================================== start(_StartType, _StartArgs) -> + my_first_nova_repo:start(), + kura_migrator:migrate(my_first_nova_repo), my_first_nova_sup:start_link(). %%-------------------------------------------------------------------- diff --git a/examples/my_first_nova/src/my_first_nova_repo.erl b/examples/my_first_nova/src/my_first_nova_repo.erl new file mode 100644 index 0000000..2fc9753 --- /dev/null +++ b/examples/my_first_nova/src/my_first_nova_repo.erl @@ -0,0 +1,51 @@ +-module(my_first_nova_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, + insert_all/2, + update_all/2, + delete_all/1, + preload/3, + transaction/1, + multi/1, + query/2 +]). + +config() -> + Database = application:get_env(my_first_nova, database, <<"my_first_nova_dev">>), + #{ + pool => ?MODULE, + database => Database, + hostname => <<"localhost">>, + port => 5432, + username => <<"postgres">>, + password => <<"postgres">>, + 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). +insert_all(Schema, Entries) -> kura_repo_worker:insert_all(?MODULE, Schema, Entries). +update_all(Q, Updates) -> kura_repo_worker:update_all(?MODULE, Q, Updates). +delete_all(Q) -> kura_repo_worker:delete_all(?MODULE, Q). +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). diff --git a/examples/my_first_nova/src/schemas/product.erl b/examples/my_first_nova/src/schemas/product.erl new file mode 100644 index 0000000..52e0892 --- /dev/null +++ b/examples/my_first_nova/src/schemas/product.erl @@ -0,0 +1,18 @@ +-module(product). +-behaviour(kura_schema). +-include_lib("kura/include/kura.hrl"). + +-export([table/0, fields/0, primary_key/0]). + +table() -> <<"products">>. +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 = price, type = integer, nullable = false}, + #kura_field{name = description, type = text}, + #kura_field{name = inserted_at, type = utc_datetime}, + #kura_field{name = updated_at, type = utc_datetime} + ]. diff --git a/examples/my_first_nova/src/schemas/user.erl b/examples/my_first_nova/src/schemas/user.erl new file mode 100644 index 0000000..43a77c6 --- /dev/null +++ b/examples/my_first_nova/src/schemas/user.erl @@ -0,0 +1,17 @@ +-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 = name, type = string, nullable = false}, + #kura_field{name = email, type = string, nullable = false}, + #kura_field{name = inserted_at, type = utc_datetime}, + #kura_field{name = updated_at, type = utc_datetime} + ]. diff --git a/examples/my_first_nova/test/my_first_nova_api_SUITE.erl b/examples/my_first_nova/test/my_first_nova_api_SUITE.erl new file mode 100644 index 0000000..8e22fd5 --- /dev/null +++ b/examples/my_first_nova/test/my_first_nova_api_SUITE.erl @@ -0,0 +1,103 @@ +-module(my_first_nova_api_SUITE). +-include_lib("nova_test/include/nova_test.hrl"). + +-export([ + all/0, + init_per_suite/1, + end_per_suite/1, + init_per_testcase/2, + end_per_testcase/2 +]). + +-export([ + products_crud/1, + users_crud/1 +]). + +all() -> [products_crud, users_crud]. + +init_per_suite(Config) -> + nova_test:start(my_first_nova, Config). + +end_per_suite(Config) -> + nova_test:stop(Config). + +init_per_testcase(_TC, Config) -> + my_first_nova_repo:query(<<"TRUNCATE products, users RESTART IDENTITY">>, []), + Config. + +end_per_testcase(_TC, _Config) -> + ok. + +%% Products + +products_crud(Config) -> + %% List (empty) + {ok, R1} = nova_test:get("/api/products", Config), + ?assertStatus(200, R1), + ?assertJson(#{<<"products">> := []}, R1), + + %% Create + {ok, R2} = nova_test:post("/api/products", #{json => #{ + <<"name">> => <<"Widget">>, + <<"price">> => 999, + <<"description">> => <<"A fine widget">> + }}, Config), + ?assertStatus(201, R2), + #{<<"id">> := ProductId} = nova_test:json(R2), + + %% Show + {ok, R3} = nova_test:get("/api/products/" ++ integer_to_list(ProductId), Config), + ?assertStatus(200, R3), + ?assertJson(#{<<"name">> := <<"Widget">>, <<"price">> := 999}, R3), + + %% Update + {ok, R4} = nova_test:put("/api/products/" ++ integer_to_list(ProductId), #{json => #{ + <<"name">> => <<"Super Widget">>, + <<"price">> => 1999 + }}, Config), + ?assertStatus(200, R4), + ?assertJson(#{<<"name">> := <<"Super Widget">>, <<"price">> := 1999}, R4), + + %% List (has one) + {ok, R5} = nova_test:get("/api/products", Config), + ?assertStatus(200, R5), + #{<<"products">> := [_]} = nova_test:json(R5), + + %% Delete + {ok, R6} = nova_test:delete("/api/products/" ++ integer_to_list(ProductId), Config), + ?assertStatus(204, R6), + + %% Show after delete (404) + {ok, R7} = nova_test:get("/api/products/" ++ integer_to_list(ProductId), Config), + ?assertStatus(404, R7), + + ok. + +%% Users + +users_crud(Config) -> + %% List (empty) + {ok, R1} = nova_test:get("/api/users", Config), + ?assertStatus(200, R1), + ?assertJson(#{<<"users">> := []}, R1), + + %% Create + {ok, R2} = nova_test:post("/api/users", #{json => #{ + <<"name">> => <<"Alice">>, + <<"email">> => <<"alice@example.com">> + }}, Config), + ?assertStatus(201, R2), + #{<<"id">> := UserId} = nova_test:json(R2), + + %% Show + {ok, R3} = nova_test:get("/api/users/" ++ integer_to_list(UserId), Config), + ?assertStatus(200, R3), + ?assertJson(#{<<"name">> := <<"Alice">>, <<"email">> := <<"alice@example.com">>}, R3), + + %% List (has one) + {ok, R4} = nova_test:get("/api/users", Config), + ?assertStatus(200, R4), + #{<<"users">> := [_]} = nova_test:json(R4), + + ok. diff --git a/examples/my_first_nova/test/my_first_nova_api_controller_tests.erl b/examples/my_first_nova/test/my_first_nova_api_controller_tests.erl index 50cbed5..153acbb 100644 --- a/examples/my_first_nova/test/my_first_nova_api_controller_tests.erl +++ b/examples/my_first_nova/test/my_first_nova_api_controller_tests.erl @@ -1,22 +1,60 @@ -module(my_first_nova_api_controller_tests). --include_lib("eunit/include/eunit.hrl"). +-include_lib("nova_test/include/nova_test.hrl"). +-include_lib("kura/include/kura.hrl"). -index_returns_users_test() -> - Req = #{}, +-define(USER, #{id => 1, name => <<"Alice">>, email => <<"alice@example.com">>}). + +setup() -> + meck:new(my_first_nova_repo, [non_strict]), + meck:new(kura_query, [non_strict]), + meck:new(kura_changeset, [non_strict]), + meck:expect(kura_query, from, fun(user) -> {query, user} end), + ok. + +cleanup(_) -> + meck:unload(). + +users_test_() -> + {foreach, fun setup/0, fun cleanup/1, [ + fun index_returns_users/0, + fun show_existing_user/0, + fun show_missing_user/0, + fun create_with_valid_params/0, + fun create_missing_params/0 + ]}. + +index_returns_users() -> + Users = [?USER], + meck:expect(my_first_nova_repo, all, fun({query, user}) -> {ok, Users} end), + Req = nova_test_req:new(get, "/api/users"), Result = my_first_nova_api_controller:index(Req), - ?assertMatch({json, #{users := [_|_]}}, Result). + ?assertJsonResponse(#{users := [_ | _]}, Result). + +show_existing_user() -> + meck:expect(my_first_nova_repo, get, fun(user, 1) -> {ok, ?USER} end), + Req = nova_test_req:with_bindings(#{<<"id">> => <<"1">>}, nova_test_req:new(get, "/api/users/1")), + Result = my_first_nova_api_controller:show(Req), + ?assertJsonResponse(#{id := 1, name := <<"Alice">>}, Result). -show_existing_user_test() -> - Req = #{bindings => #{<<"id">> => <<"1">>}}, +show_missing_user() -> + meck:expect(my_first_nova_repo, get, fun(user, 99) -> {error, not_found} end), + Req = nova_test_req:with_bindings(#{<<"id">> => <<"99">>}, nova_test_req:new(get, "/api/users/99")), Result = my_first_nova_api_controller:show(Req), - ?assertMatch({json, #{id := 1, name := _, email := _}}, Result). + ?assertMatch({status, 404, _, _}, Result). -create_with_valid_params_test() -> - Req = #{json => #{<<"name">> => <<"Charlie">>, <<"email">> => <<"charlie@example.com">>}}, +create_with_valid_params() -> + CS = #kura_changeset{valid = true}, + meck:expect(kura_changeset, cast, fun(user, #{}, _, [name, email]) -> CS end), + meck:expect(kura_changeset, validate_required, fun(C, [name, email]) -> C end), + meck:expect(my_first_nova_repo, insert, fun(_) -> {ok, ?USER} end), + Req = nova_test_req:with_json( + #{<<"name">> => <<"Alice">>, <<"email">> => <<"alice@example.com">>}, + nova_test_req:new(post, "/api/users") + ), Result = my_first_nova_api_controller:create(Req), - ?assertMatch({json, 201, #{}, #{id := 3, name := <<"Charlie">>, email := <<"charlie@example.com">>}}, Result). + ?assertJsonResponse(201, #{id := 1, name := <<"Alice">>}, Result). -create_missing_params_test() -> - Req = #{}, +create_missing_params() -> + Req = nova_test_req:new(post, "/api/users"), Result = my_first_nova_api_controller:create(Req), ?assertMatch({status, 422, _, _}, Result). diff --git a/examples/my_first_nova/test/my_first_nova_products_controller_tests.erl b/examples/my_first_nova/test/my_first_nova_products_controller_tests.erl index 122dd1d..fc166b5 100644 --- a/examples/my_first_nova/test/my_first_nova_products_controller_tests.erl +++ b/examples/my_first_nova/test/my_first_nova_products_controller_tests.erl @@ -1,33 +1,108 @@ -module(my_first_nova_products_controller_tests). --include_lib("eunit/include/eunit.hrl"). +-include_lib("nova_test/include/nova_test.hrl"). +-include_lib("kura/include/kura.hrl"). -list_returns_products_test() -> - Req = #{}, +-define(PRODUCT, #{id => 1, name => <<"Widget">>, price => 999, description => <<"A fine widget">>}). + +setup() -> + meck:new(my_first_nova_repo, [non_strict]), + meck:new(kura_query, [non_strict]), + meck:new(kura_changeset, [non_strict]), + meck:expect(kura_query, from, fun(product) -> {query, product} end), + ok. + +cleanup(_) -> + meck:unload(). + +products_test_() -> + {foreach, fun setup/0, fun cleanup/1, [ + fun list_returns_products/0, + fun show_existing_product/0, + fun show_missing_product/0, + fun create_with_valid_params/0, + fun create_missing_params/0, + fun update_product/0, + fun update_missing_product/0, + fun delete_product/0, + fun delete_missing_product/0 + ]}. + +list_returns_products() -> + Products = [?PRODUCT], + meck:expect(my_first_nova_repo, all, fun({query, product}) -> {ok, Products} end), + Req = nova_test_req:new(get, "/api/products"), Result = my_first_nova_products_controller:list(Req), - ?assertMatch({json, #{products := [_|_]}}, Result). + ?assertJsonResponse(#{products := [_ | _]}, Result). + +show_existing_product() -> + meck:expect(my_first_nova_repo, get, fun(product, 1) -> {ok, ?PRODUCT} end), + Req = nova_test_req:with_bindings(#{<<"id">> => <<"1">>}, nova_test_req:new(get, "/api/products/1")), + Result = my_first_nova_products_controller:show(Req), + ?assertJsonResponse(#{id := 1, name := <<"Widget">>}, Result). -show_existing_product_test() -> - Req = #{bindings => #{<<"id">> => <<"1">>}}, +show_missing_product() -> + meck:expect(my_first_nova_repo, get, fun(product, 99) -> {error, not_found} end), + Req = nova_test_req:with_bindings(#{<<"id">> => <<"99">>}, nova_test_req:new(get, "/api/products/99")), Result = my_first_nova_products_controller:show(Req), - ?assertMatch({json, #{id := 1, name := _, price := _}}, Result). + ?assertMatch({status, 404, _, _}, Result). -create_with_valid_params_test() -> - Req = #{params => #{<<"name">> => <<"Widget">>, <<"price">> => 999}}, +create_with_valid_params() -> + CS = #kura_changeset{valid = true}, + meck:expect(kura_changeset, cast, fun(product, #{}, _, [name, price, description]) -> CS end), + meck:expect(kura_changeset, validate_required, fun(C, [name, price]) -> C end), + meck:expect(my_first_nova_repo, insert, fun(_) -> {ok, ?PRODUCT} end), + Req = nova_test_req:with_json( + #{<<"name">> => <<"Widget">>, <<"price">> => 999}, + nova_test_req:new(post, "/api/products") + ), Result = my_first_nova_products_controller:create(Req), - ?assertMatch({json, 201, #{}, #{id := 3, name := <<"Widget">>, price := 999}}, Result). + ?assertJsonResponse(201, #{id := 1, name := <<"Widget">>}, Result). -create_missing_params_test() -> - Req = #{}, +create_missing_params() -> + Req = nova_test_req:new(post, "/api/products"), Result = my_first_nova_products_controller:create(Req), ?assertMatch({status, 422, _, _}, Result). -update_product_test() -> - Req = #{bindings => #{<<"id">> => <<"1">>}, - params => #{<<"name">> => <<"Updated">>, <<"price">> => 1500}}, +update_product() -> + Existing = ?PRODUCT, + Updated = Existing#{name => <<"Updated">>}, + CS = #kura_changeset{valid = true}, + meck:expect(my_first_nova_repo, get, fun(product, 1) -> {ok, Existing} end), + meck:expect(kura_changeset, cast, fun(product, _, _, [name, price, description]) -> CS end), + meck:expect(my_first_nova_repo, update, fun(_) -> {ok, Updated} end), + Req = nova_test_req:with_json( + #{<<"name">> => <<"Updated">>}, + nova_test_req:with_bindings(#{<<"id">> => <<"1">>}, nova_test_req:new(put, "/api/products/1")) + ), + Result = my_first_nova_products_controller:update(Req), + ?assertJsonResponse(#{name := <<"Updated">>}, Result). + +update_missing_product() -> + meck:expect(my_first_nova_repo, get, fun(product, 99) -> {error, not_found} end), + Req = nova_test_req:with_json( + #{<<"name">> => <<"Nope">>}, + nova_test_req:with_bindings(#{<<"id">> => <<"99">>}, nova_test_req:new(put, "/api/products/99")) + ), Result = my_first_nova_products_controller:update(Req), - ?assertMatch({json, #{id := 1, name := <<"Updated">>, price := 1500}}, Result). + ?assertMatch({status, 404, _, _}, Result). + +delete_product() -> + CS = #kura_changeset{valid = true}, + meck:expect(my_first_nova_repo, get, fun(product, 1) -> {ok, ?PRODUCT} end), + meck:expect(kura_changeset, cast, fun(product, _, #{}, []) -> CS end), + meck:expect(my_first_nova_repo, delete, fun(_) -> {ok, ?PRODUCT} end), + Req = nova_test_req:with_bindings( + #{<<"id">> => <<"1">>}, + nova_test_req:new(delete, "/api/products/1") + ), + Result = my_first_nova_products_controller:delete(Req), + ?assertStatusResponse(204, Result). -delete_product_test() -> - Req = #{bindings => #{<<"id">> => <<"1">>}}, +delete_missing_product() -> + meck:expect(my_first_nova_repo, get, fun(product, 99) -> {error, not_found} end), + Req = nova_test_req:with_bindings( + #{<<"id">> => <<"99">>}, + nova_test_req:new(delete, "/api/products/99") + ), Result = my_first_nova_products_controller:delete(Req), - ?assertMatch({status, 204}, Result). + ?assertMatch({status, 404, _, _}, Result). From a99a6bde1e1d7273a6e29f17300e38dba4b72322 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Sat, 14 Feb 2026 19:17:17 +0100 Subject: [PATCH 2/2] chore: apply erlfmt formatting Co-Authored-By: Claude Opus 4.6 --- examples/my_first_nova/rebar.config | 78 ++++++---- .../my_first_nova/src/my_first_nova.app.src | 37 +++-- .../my_first_nova/src/my_first_nova_auth.erl | 12 +- .../src/my_first_nova_note_repo.erl | 50 +++--- .../src/my_first_nova_router.erl | 145 ++++++++++-------- .../my_first_nova/src/my_first_nova_sup.erl | 2 +- .../test/my_first_nova_api_SUITE.erl | 44 ++++-- .../my_first_nova_api_controller_tests.erl | 8 +- .../test/my_first_nova_auth_tests.erl | 22 ++- ...y_first_nova_products_controller_tests.erl | 16 +- 10 files changed, 247 insertions(+), 167 deletions(-) diff --git a/examples/my_first_nova/rebar.config b/examples/my_first_nova/rebar.config index c21ecdb..8d2bb6f 100644 --- a/examples/my_first_nova/rebar.config +++ b/examples/my_first_nova/rebar.config @@ -4,45 +4,55 @@ {src_dirs, [{"src", [{recursive, true}]}]}. {shell, [{config, "./config/dev_sys.config.src"}]}. -{erlydtl_opts, [{doc_root, "src/views"}, - {recursive, true}, - {libraries, [ - {nova_erlydtl_inventory, nova_erlydtl_inventory} - ]}, - {default_libraries, [nova_erlydtl_inventory]} - ]}. +{erlydtl_opts, [ + {doc_root, "src/views"}, + {recursive, true}, + {libraries, [ + {nova_erlydtl_inventory, nova_erlydtl_inventory} + ]}, + {default_libraries, [nova_erlydtl_inventory]} +]}. {deps, [ - nova, - {flatlog, "0.1.2"}, %% Used for logging - Change if needed - {kura, "~> 0.3"} - ]}. - + nova, + %% Used for logging - Change if needed + {flatlog, "0.1.2"}, + {kura, "~> 0.3"} +]}. %% Release profiles %% To create a release just run %% rebar3 as prod release -{relx, [{release, {my_first_nova, git}, - [my_first_nova, - sasl]}, - {mode, dev}, - {sys_config_src, "./config/dev_sys.config.src"}, - {vm_args_src, "./config/vm.args.src"} - ]}. - -{profiles, [{prod, [{relx, - [{mode, prod}, - {sys_config_src, "./config/prod_sys.config.src"}]}]}, - {test, [{deps, [meck, nova_test]}, - {ct_opts, [{sys_config, ["config/test_sys.config"]}]}]}]}. +{relx, [ + {release, {my_first_nova, git}, [ + my_first_nova, + sasl + ]}, + {mode, dev}, + {sys_config_src, "./config/dev_sys.config.src"}, + {vm_args_src, "./config/vm.args.src"} +]}. + +{profiles, [ + {prod, [ + {relx, [ + {mode, prod}, + {sys_config_src, "./config/prod_sys.config.src"} + ]} + ]}, + {test, [ + {deps, [meck, nova_test]}, + {ct_opts, [{sys_config, ["config/test_sys.config"]}]} + ]} +]}. %% Plugins for rebar3 {plugins, [ - {rebar3_erlydtl_plugin, ".*", - {git, "https://github.com/tsloughter/rebar3_erlydtl_plugin.git", {branch, "master"}}}, - {rebar3_nova, ".*", - {git, "https://github.com/novaframework/rebar3_nova.git", {branch, "master"}}} - ]}. + {rebar3_erlydtl_plugin, ".*", + {git, "https://github.com/tsloughter/rebar3_erlydtl_plugin.git", {branch, "master"}}}, + {rebar3_nova, ".*", + {git, "https://github.com/novaframework/rebar3_nova.git", {branch, "master"}}} +]}. {project_plugins, [ {rebar3_kura, "~> 0.3"}, @@ -50,6 +60,8 @@ ]}. {provider_hooks, [ - {pre, [{compile, {erlydtl, compile}}, - {compile, {kura, compile}}]} - ]}. + {pre, [ + {compile, {erlydtl, compile}}, + {compile, {kura, compile}} + ]} +]}. diff --git a/examples/my_first_nova/src/my_first_nova.app.src b/examples/my_first_nova/src/my_first_nova.app.src index 22bc64a..eca00fb 100644 --- a/examples/my_first_nova/src/my_first_nova.app.src +++ b/examples/my_first_nova/src/my_first_nova.app.src @@ -1,20 +1,19 @@ %% -*- mode: erlang;erlang-indent-level: 4;indent-tabs-mode: nil -*- -{application, my_first_nova, - [{description, "my_first_nova managed by Nova"}, - {vsn, git}, - {registered, []}, - {mod, { my_first_nova_app, []}}, - {included_applications, []}, - {applications, - [ - kernel, - stdlib, - nova, - pgo - ]}, - {env,[]}, - {modules, []}, - {maintainers, []}, - {licenses, ["Apache 2.0"]}, - {links, []} - ]}. +{application, my_first_nova, [ + {description, "my_first_nova managed by Nova"}, + {vsn, git}, + {registered, []}, + {mod, {my_first_nova_app, []}}, + {included_applications, []}, + {applications, [ + kernel, + stdlib, + nova, + pgo + ]}, + {env, []}, + {modules, []}, + {maintainers, []}, + {licenses, ["Apache 2.0"]}, + {links, []} +]}. diff --git a/examples/my_first_nova/src/my_first_nova_auth.erl b/examples/my_first_nova/src/my_first_nova_auth.erl index 9759c7a..26d7c76 100644 --- a/examples/my_first_nova/src/my_first_nova_auth.erl +++ b/examples/my_first_nova/src/my_first_nova_auth.erl @@ -1,14 +1,16 @@ -module(my_first_nova_auth). -export([ - username_password/1, - session_auth/1 - ]). + username_password/1, + session_auth/1 +]). %% Used for the login POST username_password(#{params := Params}) -> case Params of - #{<<"username">> := Username, - <<"password">> := <<"password">>} -> + #{ + <<"username">> := Username, + <<"password">> := <<"password">> + } -> {true, #{authed => true, username => Username}}; _ -> false diff --git a/examples/my_first_nova/src/my_first_nova_note_repo.erl b/examples/my_first_nova/src/my_first_nova_note_repo.erl index 9791041..d607977 100644 --- a/examples/my_first_nova/src/my_first_nova_note_repo.erl +++ b/examples/my_first_nova/src/my_first_nova_note_repo.erl @@ -1,14 +1,18 @@ -module(my_first_nova_note_repo). -export([ - all/0, - get/1, - create/3, - update/3, - delete/1 - ]). + 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 + 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} -> @@ -26,9 +30,13 @@ get(Id) -> 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 + 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} -> @@ -36,9 +44,13 @@ create(Title, Body, Author) -> 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 + 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 := []} -> @@ -55,8 +67,10 @@ delete(Id) -> end. row_to_map({Id, Title, Body, Author, InsertedAt}) -> - #{id => Id, - title => Title, - body => Body, - author => Author, - inserted_at => InsertedAt}. + #{ + id => Id, + title => Title, + body => Body, + author => Author, + inserted_at => InsertedAt + }. diff --git a/examples/my_first_nova/src/my_first_nova_router.erl b/examples/my_first_nova/src/my_first_nova_router.erl index dac0a26..c591b75 100644 --- a/examples/my_first_nova/src/my_first_nova_router.erl +++ b/examples/my_first_nova/src/my_first_nova_router.erl @@ -2,76 +2,89 @@ -behaviour(nova_router). -export([ - routes/1 - ]). + routes/1 +]). routes(_Environment) -> - [ - %% Error handlers - #{routes => [ - {404, fun my_first_nova_error_controller:not_found/1, #{}}, - {500, fun my_first_nova_error_controller:server_error/1, #{}} - ]}, + [ + %% Error handlers + #{ + routes => [ + {404, fun my_first_nova_error_controller:not_found/1, #{}}, + {500, fun my_first_nova_error_controller:server_error/1, #{}} + ] + }, - %% 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}}, - {"/chat", my_first_nova_chat_handler, #{protocol => ws}}, - {"/notifications", my_first_nova_notifications_handler, #{protocol => ws}} - ] - }, + %% 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}}, + {"/chat", my_first_nova_chat_handler, #{protocol => ws}}, + {"/notifications", my_first_nova_notifications_handler, #{protocol => ws}} + ] + }, - %% 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]}} - ] - }, + %% 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]}} - ] - }, + %% 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]}} + ] + }, - %% 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]}} - ] - }, + %% 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 - #{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]}}, - {"/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]}}, - {"/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]}} - ] - } - ]. + %% JSON API + #{ + 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] + }}, + {"/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]}}, + {"/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] + }} + ] + } + ]. diff --git a/examples/my_first_nova/src/my_first_nova_sup.erl b/examples/my_first_nova/src/my_first_nova_sup.erl index 28cec15..3b7be10 100644 --- a/examples/my_first_nova/src/my_first_nova_sup.erl +++ b/examples/my_first_nova/src/my_first_nova_sup.erl @@ -28,7 +28,7 @@ start_link() -> %% Child :: {Id,StartFunc,Restart,Shutdown,Type,Modules} init([]) -> - {ok, { {one_for_all, 0, 1}, []} }. + {ok, {{one_for_all, 0, 1}, []}}. %%==================================================================== %% Internal functions diff --git a/examples/my_first_nova/test/my_first_nova_api_SUITE.erl b/examples/my_first_nova/test/my_first_nova_api_SUITE.erl index 8e22fd5..63fc08a 100644 --- a/examples/my_first_nova/test/my_first_nova_api_SUITE.erl +++ b/examples/my_first_nova/test/my_first_nova_api_SUITE.erl @@ -38,11 +38,17 @@ products_crud(Config) -> ?assertJson(#{<<"products">> := []}, R1), %% Create - {ok, R2} = nova_test:post("/api/products", #{json => #{ - <<"name">> => <<"Widget">>, - <<"price">> => 999, - <<"description">> => <<"A fine widget">> - }}, Config), + {ok, R2} = nova_test:post( + "/api/products", + #{ + json => #{ + <<"name">> => <<"Widget">>, + <<"price">> => 999, + <<"description">> => <<"A fine widget">> + } + }, + Config + ), ?assertStatus(201, R2), #{<<"id">> := ProductId} = nova_test:json(R2), @@ -52,10 +58,16 @@ products_crud(Config) -> ?assertJson(#{<<"name">> := <<"Widget">>, <<"price">> := 999}, R3), %% Update - {ok, R4} = nova_test:put("/api/products/" ++ integer_to_list(ProductId), #{json => #{ - <<"name">> => <<"Super Widget">>, - <<"price">> => 1999 - }}, Config), + {ok, R4} = nova_test:put( + "/api/products/" ++ integer_to_list(ProductId), + #{ + json => #{ + <<"name">> => <<"Super Widget">>, + <<"price">> => 1999 + } + }, + Config + ), ?assertStatus(200, R4), ?assertJson(#{<<"name">> := <<"Super Widget">>, <<"price">> := 1999}, R4), @@ -83,10 +95,16 @@ users_crud(Config) -> ?assertJson(#{<<"users">> := []}, R1), %% Create - {ok, R2} = nova_test:post("/api/users", #{json => #{ - <<"name">> => <<"Alice">>, - <<"email">> => <<"alice@example.com">> - }}, Config), + {ok, R2} = nova_test:post( + "/api/users", + #{ + json => #{ + <<"name">> => <<"Alice">>, + <<"email">> => <<"alice@example.com">> + } + }, + Config + ), ?assertStatus(201, R2), #{<<"id">> := UserId} = nova_test:json(R2), diff --git a/examples/my_first_nova/test/my_first_nova_api_controller_tests.erl b/examples/my_first_nova/test/my_first_nova_api_controller_tests.erl index 153acbb..7babd01 100644 --- a/examples/my_first_nova/test/my_first_nova_api_controller_tests.erl +++ b/examples/my_first_nova/test/my_first_nova_api_controller_tests.erl @@ -32,13 +32,17 @@ index_returns_users() -> show_existing_user() -> meck:expect(my_first_nova_repo, get, fun(user, 1) -> {ok, ?USER} end), - Req = nova_test_req:with_bindings(#{<<"id">> => <<"1">>}, nova_test_req:new(get, "/api/users/1")), + Req = nova_test_req:with_bindings( + #{<<"id">> => <<"1">>}, nova_test_req:new(get, "/api/users/1") + ), Result = my_first_nova_api_controller:show(Req), ?assertJsonResponse(#{id := 1, name := <<"Alice">>}, Result). show_missing_user() -> meck:expect(my_first_nova_repo, get, fun(user, 99) -> {error, not_found} end), - Req = nova_test_req:with_bindings(#{<<"id">> => <<"99">>}, nova_test_req:new(get, "/api/users/99")), + Req = nova_test_req:with_bindings( + #{<<"id">> => <<"99">>}, nova_test_req:new(get, "/api/users/99") + ), Result = my_first_nova_api_controller:show(Req), ?assertMatch({status, 404, _, _}, Result). diff --git a/examples/my_first_nova/test/my_first_nova_auth_tests.erl b/examples/my_first_nova/test/my_first_nova_auth_tests.erl index bc5115c..4bd2b8c 100644 --- a/examples/my_first_nova/test/my_first_nova_auth_tests.erl +++ b/examples/my_first_nova/test/my_first_nova_auth_tests.erl @@ -2,14 +2,24 @@ -include_lib("eunit/include/eunit.hrl"). valid_login_test() -> - Req = #{params => #{<<"username">> => <<"admin">>, - <<"password">> => <<"password">>}}, - ?assertMatch({true, #{authed := true, username := <<"admin">>}}, - my_first_nova_auth:username_password(Req)). + Req = #{ + params => #{ + <<"username">> => <<"admin">>, + <<"password">> => <<"password">> + } + }, + ?assertMatch( + {true, #{authed := true, username := <<"admin">>}}, + my_first_nova_auth:username_password(Req) + ). invalid_password_test() -> - Req = #{params => #{<<"username">> => <<"admin">>, - <<"password">> => <<"wrong">>}}, + Req = #{ + params => #{ + <<"username">> => <<"admin">>, + <<"password">> => <<"wrong">> + } + }, ?assertEqual(false, my_first_nova_auth:username_password(Req)). missing_params_test() -> diff --git a/examples/my_first_nova/test/my_first_nova_products_controller_tests.erl b/examples/my_first_nova/test/my_first_nova_products_controller_tests.erl index fc166b5..0bca3b8 100644 --- a/examples/my_first_nova/test/my_first_nova_products_controller_tests.erl +++ b/examples/my_first_nova/test/my_first_nova_products_controller_tests.erl @@ -36,13 +36,17 @@ list_returns_products() -> show_existing_product() -> meck:expect(my_first_nova_repo, get, fun(product, 1) -> {ok, ?PRODUCT} end), - Req = nova_test_req:with_bindings(#{<<"id">> => <<"1">>}, nova_test_req:new(get, "/api/products/1")), + Req = nova_test_req:with_bindings( + #{<<"id">> => <<"1">>}, nova_test_req:new(get, "/api/products/1") + ), Result = my_first_nova_products_controller:show(Req), ?assertJsonResponse(#{id := 1, name := <<"Widget">>}, Result). show_missing_product() -> meck:expect(my_first_nova_repo, get, fun(product, 99) -> {error, not_found} end), - Req = nova_test_req:with_bindings(#{<<"id">> => <<"99">>}, nova_test_req:new(get, "/api/products/99")), + Req = nova_test_req:with_bindings( + #{<<"id">> => <<"99">>}, nova_test_req:new(get, "/api/products/99") + ), Result = my_first_nova_products_controller:show(Req), ?assertMatch({status, 404, _, _}, Result). @@ -72,7 +76,9 @@ update_product() -> meck:expect(my_first_nova_repo, update, fun(_) -> {ok, Updated} end), Req = nova_test_req:with_json( #{<<"name">> => <<"Updated">>}, - nova_test_req:with_bindings(#{<<"id">> => <<"1">>}, nova_test_req:new(put, "/api/products/1")) + nova_test_req:with_bindings( + #{<<"id">> => <<"1">>}, nova_test_req:new(put, "/api/products/1") + ) ), Result = my_first_nova_products_controller:update(Req), ?assertJsonResponse(#{name := <<"Updated">>}, Result). @@ -81,7 +87,9 @@ update_missing_product() -> meck:expect(my_first_nova_repo, get, fun(product, 99) -> {error, not_found} end), Req = nova_test_req:with_json( #{<<"name">> => <<"Nope">>}, - nova_test_req:with_bindings(#{<<"id">> => <<"99">>}, nova_test_req:new(put, "/api/products/99")) + nova_test_req:with_bindings( + #{<<"id">> => <<"99">>}, nova_test_req:new(put, "/api/products/99") + ) ), Result = my_first_nova_products_controller:update(Req), ?assertMatch({status, 404, _, _}, Result).