diff --git a/.github/workflows/erlang.yml b/.github/workflows/erlang.yml
index 3d5bab7..d00548a 100644
--- a/.github/workflows/erlang.yml
+++ b/.github/workflows/erlang.yml
@@ -5,6 +5,9 @@ on:
branches: [master]
pull_request:
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
jobs:
build:
runs-on: ubuntu-24.04
@@ -12,8 +15,8 @@ jobs:
strategy:
fail-fast: false
matrix:
- otp: ['26.1', '27.1', '28.0']
- rebar3: ['3.25.0']
+ otp: ['27.3', '28.3']
+ rebar3: ['3.26.0']
steps:
- uses: actions/checkout@v6
- uses: erlef/setup-beam@v1
@@ -22,7 +25,7 @@ jobs:
rebar3-version: ${{matrix.rebar3}}
version-type: strict
- name: Cache rebar3 deps and build
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
~/.cache/rebar3
@@ -39,8 +42,8 @@ jobs:
strategy:
fail-fast: false
matrix:
- otp: ['26.1', '27.1', '28.0']
- rebar3: ['3.25.0']
+ otp: ['27.3', '28.3']
+ rebar3: ['3.26.0']
steps:
- uses: actions/checkout@v6
- uses: erlef/setup-beam@v1
@@ -49,7 +52,7 @@ jobs:
rebar3-version: ${{matrix.rebar3}}
version-type: strict
- name: Cache rebar3 deps and build
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
~/.cache/rebar3
@@ -65,8 +68,8 @@ jobs:
strategy:
fail-fast: false
matrix:
- otp: ['26.1', '27.1', '28.0']
- rebar3: ['3.25.0']
+ otp: ['27.3', '28.3']
+ rebar3: ['3.26.0']
steps:
- uses: actions/checkout@v6
- uses: erlef/setup-beam@v1
@@ -75,7 +78,7 @@ jobs:
rebar3-version: ${{matrix.rebar3}}
version-type: strict
- name: Cache rebar3 deps and build
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
~/.cache/rebar3
@@ -91,8 +94,8 @@ jobs:
strategy:
fail-fast: false
matrix:
- otp: ['26.1', '27.1', '28.0']
- rebar3: ['3.25.0']
+ otp: ['27.3', '28.3']
+ rebar3: ['3.26.0']
steps:
- uses: actions/checkout@v6
- uses: erlef/setup-beam@v1
@@ -101,7 +104,7 @@ jobs:
rebar3-version: ${{matrix.rebar3}}
version-type: strict
- name: Cache rebar3 deps and build
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
~/.cache/rebar3
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5f6f3dd..fe7f1c0 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -11,7 +11,7 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
diff --git a/guides/configuration.md b/guides/configuration.md
index 6b14eee..8d92b8b 100644
--- a/guides/configuration.md
+++ b/guides/configuration.md
@@ -41,18 +41,18 @@ These parameters can be specified in your *main* application (Eg the one you've
| Key | Description | Value |
|-----|-------------|-------|
-| `json_lib` | JSON lib to use. Read more in the subsection *Configure json lib* | `atom()` |
-| `watchers` | Watchers are external programs that will run together with Nova. Watchers are defined as list of tuples where the tuples is in format `{Command, ArgumentList}` (Like `[{my_app, "npm", ["run", "watch"], #{workdir => "priv/assets/js/my-app"}}]`) | `[{string(), string()}] | [{atom(), string(), map()}] | [{atom(), string(), list(), map()}]` |
+| `json_lib` | JSON lib to use. Defaults to the Erlang/OTP `json` module. Read more in the subsection *Configure json lib* | `atom()` |
+| `watchers` | Watchers are external programs that will run together with Nova. Watchers are defined as list of tuples where the tuples is in format `{Command, ArgumentList}` (Like `[{my_app, "npm", ["run", "watch"], #{workdir => "priv/assets/js/my-app"}}]`) | `[{string(), string()}] | [{atom(), string(), list(), map()}]` |
### Configure json_lib
-One can configure which json library to use for encoding/decoding json structures. The module defined for this should expose two different functions:
+By default Nova uses the Erlang/OTP `json` module (available since OTP 27). You can configure a custom JSON library for use with other BEAM languages (Gleam, LFE, etc.). The module must expose two functions:
-`encode(Structure) -> binary() | iolist()`
+`encode(Term) -> iodata()`
-`decode(JsonString) -> {ok, Structure}`
+`decode(Binary) -> Term` (raises on error)
## Handling errors in Nova
diff --git a/rebar.config b/rebar.config
index 62a190b..f5884f1 100644
--- a/rebar.config
+++ b/rebar.config
@@ -14,8 +14,7 @@
{cowboy, "2.13.0"},
{erlydtl, "0.14.0"},
{jhn_stdlib, "5.4.0"},
- {routing_tree, "1.0.11"},
- {thoas, "1.2.1"}
+ {routing_tree, "1.0.11"}
]}.
{profiles, [
diff --git a/rebar.lock b/rebar.lock
index e38da47..d5a3247 100644
--- a/rebar.lock
+++ b/rebar.lock
@@ -1,26 +1,23 @@
{"1.2.0",
[{<<"cowboy">>,{pkg,<<"cowboy">>,<<"2.13.0">>},0},
- {<<"cowlib">>,{pkg,<<"cowlib">>,<<"2.13.0">>},1},
+ {<<"cowlib">>,{pkg,<<"cowlib">>,<<"2.16.0">>},1},
{<<"erlydtl">>,{pkg,<<"erlydtl">>,<<"0.14.0">>},0},
{<<"jhn_stdlib">>,{pkg,<<"jhn_stdlib">>,<<"5.4.0">>},0},
- {<<"ranch">>,{pkg,<<"ranch">>,<<"2.1.0">>},1},
- {<<"routing_tree">>,{pkg,<<"routing_tree">>,<<"1.0.11">>},0},
- {<<"thoas">>,{pkg,<<"thoas">>,<<"1.2.1">>},0}]}.
+ {<<"ranch">>,{pkg,<<"ranch">>,<<"2.2.0">>},1},
+ {<<"routing_tree">>,{pkg,<<"routing_tree">>,<<"1.0.11">>},0}]}.
[
{pkg_hash,[
{<<"cowboy">>, <<"09D770DD5F6A22CC60C071F432CD7CB87776164527F205C5A6B0F24FF6B38990">>},
- {<<"cowlib">>, <<"DB8F7505D8332D98EF50A3EF34B34C1AFDDEC7506E4EE4DD4A3A266285D282CA">>},
+ {<<"cowlib">>, <<"54592074EBBBB92EE4746C8A8846E5605052F29309D3A873468D76CDF932076F">>},
{<<"erlydtl">>, <<"964B2DC84F8C17ACFAA69C59BA129EF26AC45D2BA898C3C6AD9B5BDC8BA13CED">>},
{<<"jhn_stdlib">>, <<"FAC6F19B35351278F1CB156E23A5B2A6047A9DD5AB1FD9E1189A7918006DF7ED">>},
- {<<"ranch">>, <<"2261F9ED9574DCFCC444106B9F6DA155E6E540B2F82BA3D42B339B93673B72A3">>},
- {<<"routing_tree">>, <<"72ACEF2095F0EC804F7AFD07EF781DDE5009425A1CA0A28F0706B1DB334A4812">>},
- {<<"thoas">>, <<"19A25F31177A17E74004D4840F66D791D4298C5738790FA2CC73731EB911F195">>}]},
+ {<<"ranch">>, <<"25528F82BC8D7C6152C57666CA99EC716510FE0925CB188172F41CE93117B1B0">>},
+ {<<"routing_tree">>, <<"72ACEF2095F0EC804F7AFD07EF781DDE5009425A1CA0A28F0706B1DB334A4812">>}]},
{pkg_hash_ext,[
{<<"cowboy">>, <<"E724D3A70995025D654C1992C7B11DBFEA95205C047D86FF9BF1CDA92DDC5614">>},
- {<<"cowlib">>, <<"E1E1284DC3FC030A64B1AD0D8382AE7E99DA46C3246B815318A4B848873800A4">>},
+ {<<"cowlib">>, <<"7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51">>},
{<<"erlydtl">>, <<"D80EC044CD8F58809C19D29AC5605BE09E955040911B644505E31E9DD8143431">>},
{<<"jhn_stdlib">>, <<"7EABD1B01D2DEFF495BF7C5CA1DBA4D3FA0B84DC3AF03CA85F31D52EBB03C6FC">>},
- {<<"ranch">>, <<"244EE3FA2A6175270D8E1FC59024FD9DBC76294A321057DE8F803B1479E76916">>},
- {<<"routing_tree">>, <<"85982C7AC502892C5179CD2A591331003BACD2D2A71723640BA7D23F45408E6E">>},
- {<<"thoas">>, <<"E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A">>}]}
+ {<<"ranch">>, <<"FA0B99A1780C80218A4197A59EA8D3BDAE32FBFF7E88527D7D8A4787EFF4F8E7">>},
+ {<<"routing_tree">>, <<"85982C7AC502892C5179CD2A591331003BACD2D2A71723640BA7D23F45408E6E">>}]}
].
diff --git a/src/controllers/nova_error_controller.erl b/src/controllers/nova_error_controller.erl
index f38598d..dbaf952 100644
--- a/src/controllers/nova_error_controller.erl
+++ b/src/controllers/nova_error_controller.erl
@@ -23,8 +23,8 @@ not_found(Req) ->
lists:member(<<"text/html">>, AcceptList)} of
{true, _} ->
%% Render a json response
- JsonLib = nova:get_env(json_lib, thoas),
- Json = JsonLib:encode(#{message => "Resource not found"}),
+ JsonLib = nova:get_env(json_lib, json),
+ Json = JsonLib:encode(#{message => <<"Resource not found">>}),
{status, 404, #{<<"content-type">> => <<"application/json">>}, Json};
{_, true} ->
%% Just assume HTML
@@ -47,8 +47,8 @@ server_error(#{crash_info := #{status_code := StatusCode} = CrashInfo} = Req) ->
true ->
case cowboy_req:header(<<"accept">>, Req) of
<<"application/json">> ->
- JsonLib = nova:get_env(json_lib, thoas),
- Json = JsonLib:encode(Variables),
+ JsonLib = nova:get_env(json_lib, json),
+ Json = JsonLib:encode(ensure_json_safe(Variables)),
{status, StatusCode, #{<<"content-type">> => <<"application/json">>}, Json};
<<"text/html">> ->
{ok, Body} = nova_error_dtl:render(Variables),
@@ -61,10 +61,10 @@ server_error(#{crash_info := #{status_code := StatusCode} = CrashInfo} = Req) ->
end;
server_error(#{crash_info := #{class := Class, reason := Reason}} = Req) ->
Stacktrace = maps:get(stacktrace, Req, []),
- Variables = #{status => "Internal Server Error",
- title => "500 Internal Server Error",
- message => "Something internal crashed. Please take a look!",
- extra_msg => io_lib:format("Class: ~p
Reason: ~p", [Class, Reason]),
+ Variables = #{status => <<"Internal Server Error">>,
+ title => <<"500 Internal Server Error">>,
+ message => <<"Something internal crashed. Please take a look!">>,
+ extra_msg => iolist_to_binary(io_lib:format("Class: ~p
Reason: ~p", [Class, Reason])),
stacktrace => format_stacktrace(Stacktrace)},
case nova:get_environment() of
@@ -72,7 +72,7 @@ server_error(#{crash_info := #{class := Class, reason := Reason}} = Req) ->
%% We do show a proper error response
case cowboy_req:header(<<"accept">>, Req) of
<<"application/json">> ->
- JsonLib = nova:get_env(json_lib, thoas),
+ JsonLib = nova:get_env(json_lib, json),
Json = JsonLib:encode(Variables),
{status, 500, #{<<"content-type">> => <<"application/json">>}, Json};
<<"text/html">> ->
@@ -123,6 +123,16 @@ format_arity(Arity, _) when is_function(Arity)->
format_arity(Arity, _) ->
Arity.
+ensure_json_safe(Map) when is_map(Map) ->
+ maps:map(fun(_K, V) -> ensure_json_safe(V) end, Map);
+ensure_json_safe(List) when is_list(List) ->
+ case io_lib:printable_unicode_list(List) of
+ true -> unicode:characters_to_binary(List);
+ false -> [ensure_json_safe(E) || E <- List]
+ end;
+ensure_json_safe(Value) ->
+ Value.
+
-ifdef(TEST).
-compile(export_all).
-endif.
diff --git a/src/nova.app.src b/src/nova.app.src
index 2d02663..458aa28 100644
--- a/src/nova.app.src
+++ b/src/nova.app.src
@@ -13,8 +13,7 @@
compiler,
erlydtl,
jhn_stdlib,
- routing_tree,
- thoas
+ routing_tree
]},
{env,[]},
{modules,[nova]},
diff --git a/src/nova_basic_handler.erl b/src/nova_basic_handler.erl
index ad44095..3103d06 100644
--- a/src/nova_basic_handler.erl
+++ b/src/nova_basic_handler.erl
@@ -43,7 +43,7 @@
handle_json({json, StatusCode, Headers, Req0, JSON}, Callback, _Req) ->
handle_json({json, StatusCode, Headers, JSON}, Callback, Req0);
handle_json({json, StatusCode, Headers, JSON}, _Callback, Req) ->
- JsonLib = nova:get_env(json_lib, thoas),
+ JsonLib = nova:get_env(json_lib, json),
EncodedJSON = JsonLib:encode(JSON),
Headers0 = maps:merge(#{<<"content-type">> => <<"application/json">>}, Headers),
Req0 = cowboy_req:set_resp_headers(Headers0, Req),
@@ -154,7 +154,7 @@ handle_status({status, Status, ExtraHeaders, JSON, Req0}, Callback, _Req) ->
handle_status({status, Status, ExtraHeaders, JSON}, Callback, Req0);
handle_status({status, Status, ExtraHeaders, JSON}, _Callback, Req) when is_map(JSON) ->
%% We do not need to render a status page since we just return a JSON structure
- JsonLib = nova:get_env(json_lib, thoas),
+ JsonLib = nova:get_env(json_lib, json),
Headers0 = maps:merge(#{<<"content-type">> => <<"application/json">>}, ExtraHeaders),
Req0 = cowboy_req:set_resp_headers(Headers0, Req),
Req1 = Req0#{resp_status_code => Status},
diff --git a/src/nova_jsonlogger.erl b/src/nova_jsonlogger.erl
index c843331..a0a28b0 100644
--- a/src/nova_jsonlogger.erl
+++ b/src/nova_jsonlogger.erl
@@ -85,7 +85,7 @@ merge_meta(Msg, Meta0, Config) ->
maps:merge(Msg, Meta2).
encode(Data, Config) ->
- JsonLib = nova:get_env(json_lib, thoas),
+ JsonLib = nova:get_env(json_lib, json),
Json = JsonLib:encode(Data),
case new_line(Config) of
true -> [Json, new_line_type(Config)];
@@ -162,7 +162,7 @@ meta_with(Meta, _ConfigNotPresent) ->
-include_lib("eunit/include/eunit.hrl").
-define(assertJSONEqual(Expected, Actual),
- ?assertEqual(thoas:decode(Expected), thoas:decode(Actual))
+ ?assertEqual(json:decode(Expected), json:decode(iolist_to_binary(Actual)))
).
format_test() ->
@@ -260,20 +260,20 @@ meta_without_test() ->
meta => #{secret => xyz}
},
?assertEqual(
- {ok, #{
+ #{
<<"answer">> => 42,
<<"level">> => <<"info">>,
<<"secret">> => <<"xyz">>
- }},
- thoas:decode(format(Error, #{}))
+ },
+ json:decode(iolist_to_binary(format(Error, #{})))
),
Config2 = #{meta_without => [secret]},
?assertEqual(
- {ok, #{
+ #{
<<"answer">> => 42,
<<"level">> => <<"info">>
- }},
- thoas:decode(format(Error, Config2))
+ },
+ json:decode(iolist_to_binary(format(Error, Config2)))
),
ok.
@@ -284,36 +284,34 @@ meta_with_test() ->
meta => #{secret => xyz}
},
?assertEqual(
- {ok, #{
+ #{
<<"answer">> => 42,
<<"level">> => <<"info">>,
<<"secret">> => <<"xyz">>
- }},
- thoas:decode(format(Error, #{}))
+ },
+ json:decode(iolist_to_binary(format(Error, #{})))
),
Config2 = #{meta_with => [level]},
?assertEqual(
- {ok, #{
+ #{
<<"answer">> => 42,
<<"level">> => <<"info">>
- }},
- thoas:decode(format(Error, Config2))
+ },
+ json:decode(iolist_to_binary(format(Error, Config2)))
),
ok.
newline_test() ->
ConfigDefault = #{new_line => true},
- ?assertEqual(
- [<<"{\"level\":\"alert\",\"text\":\"derp\"}">>, <<"\n">>],
- format(#{level => alert, msg => {string, "derp"}, meta => #{}}, ConfigDefault)
- ),
- ConfigCRLF = #{
- new_line_type => crlf,
- new_line => true
- },
- ?assertEqual(
- [<<"{\"level\":\"alert\",\"text\":\"derp\"}">>, <<"\r\n">>],
- format(#{level => alert, msg => {string, "derp"}, meta => #{}}, ConfigCRLF)
- ).
+ [JsonDefault, NlDefault] = format(#{level => alert, msg => {string, "derp"}, meta => #{}}, ConfigDefault),
+ ?assertEqual(#{<<"level">> => <<"alert">>, <<"text">> => <<"derp">>},
+ json:decode(iolist_to_binary(JsonDefault))),
+ ?assertEqual(<<"\n">>, NlDefault),
+
+ ConfigCRLF = #{new_line_type => crlf, new_line => true},
+ [JsonCRLF, NlCRLF] = format(#{level => alert, msg => {string, "derp"}, meta => #{}}, ConfigCRLF),
+ ?assertEqual(#{<<"level">> => <<"alert">>, <<"text">> => <<"derp">>},
+ json:decode(iolist_to_binary(JsonCRLF))),
+ ?assertEqual(<<"\r\n">>, NlCRLF).
-endif.
\ No newline at end of file
diff --git a/src/plugins/nova_request_plugin.erl b/src/plugins/nova_request_plugin.erl
index 7421d68..17467e7 100644
--- a/src/plugins/nova_request_plugin.erl
+++ b/src/plugins/nova_request_plugin.erl
@@ -78,15 +78,16 @@ modulate_state(Req = #{headers := #{<<"content-type">> := <<"application/json",
{stop, Req400, State};
modulate_state(Req = #{headers := #{<<"content-type">> := <<"application/json", _/binary>>}, body := Body}, [{decode_json_body, true}|Tl], State) ->
%% Decode the data
- JsonLib = nova:get_env(json_lib, thoas),
- case JsonLib:decode(Body) of
- {ok, JSON} ->
- modulate_state(Req#{json => JSON}, Tl, State);
- Error ->
+ JsonLib = nova:get_env(json_lib, json),
+ try JsonLib:decode(Body) of
+ JSON ->
+ modulate_state(Req#{json => JSON}, Tl, State)
+ catch
+ error:Reason ->
Req400 = cowboy_req:reply(400, Req),
logger:warning(#{status_code => 400,
msg => "Failed to decode json.",
- error => Error}),
+ error => Reason}),
{stop, Req400, State}
end;
modulate_state(#{headers := #{<<"content-type">> := <<"application/x-www-form-urlencoded", _/binary>>}, body := Body} = Req,
diff --git a/test/nova_error_controller_tests.erl b/test/nova_error_controller_tests.erl
index 159b066..fc735b9 100644
--- a/test/nova_error_controller_tests.erl
+++ b/test/nova_error_controller_tests.erl
@@ -69,7 +69,7 @@ not_found_json_accept_test_() ->
Req1 = nova_test_helper:with_header(<<"accept">>, <<"application/json">>, Req),
{status, 404, Headers, Body} = nova_error_controller:not_found(Req1),
?assertEqual(<<"application/json">>, maps:get(<<"content-type">>, Headers)),
- ?assert(is_binary(Body))
+ ?assert(is_list(Body) orelse is_binary(Body))
end}.
%% When no accept header, defaults to JSON
diff --git a/test/nova_test_helper.erl b/test/nova_test_helper.erl
index bf7ca42..e93948d 100644
--- a/test/nova_test_helper.erl
+++ b/test/nova_test_helper.erl
@@ -32,7 +32,8 @@ with_header(Name, Value, Req = #{headers := Headers}) ->
-spec with_json_body(map() | binary(), map()) -> map().
with_json_body(JSON, Req) when is_map(JSON) ->
- Body = thoas:encode(JSON),
+ JsonLib = nova:get_env(json_lib, json),
+ Body = iolist_to_binary(JsonLib:encode(JSON)),
with_json_body(Body, Req);
with_json_body(Body, Req) when is_binary(Body) ->
Req1 = with_content_type(<<"application/json">>, Req),
@@ -61,7 +62,6 @@ setup_nova_env() ->
Prev = application:get_env(nova, bootstrap_application),
application:set_env(nova, bootstrap_application, nova),
application:set_env(nova, environment, dev),
- application:set_env(nova, json_lib, thoas),
Prev.
-spec cleanup_nova_env(undefined | {ok, atom()}) -> ok.