Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 15 additions & 12 deletions .github/workflows/erlang.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ on:
branches: [master]
pull_request:

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
build:
runs-on: ubuntu-24.04
name: Erlang/OTP ${{matrix.otp}} / rebar3 ${{matrix.rebar3}}
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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0

Expand Down
10 changes: 5 additions & 5 deletions guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -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, [
Expand Down
21 changes: 9 additions & 12 deletions rebar.lock
Original file line number Diff line number Diff line change
@@ -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">>}]}
].
28 changes: 19 additions & 9 deletions src/controllers/nova_error_controller.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand All @@ -61,18 +61,18 @@ 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<br /> 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<br /> Reason: ~p", [Class, Reason])),
stacktrace => format_stacktrace(Stacktrace)},

case nova:get_environment() of
dev ->
%% 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">> ->
Expand Down Expand Up @@ -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.
3 changes: 1 addition & 2 deletions src/nova.app.src
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@
compiler,
erlydtl,
jhn_stdlib,
routing_tree,
thoas
routing_tree
]},
{env,[]},
{modules,[nova]},
Expand Down
4 changes: 2 additions & 2 deletions src/nova_basic_handler.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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},
Expand Down
50 changes: 24 additions & 26 deletions src/nova_jsonlogger.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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)];
Expand Down Expand Up @@ -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() ->
Expand Down Expand Up @@ -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.

Expand All @@ -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.
13 changes: 7 additions & 6 deletions src/plugins/nova_request_plugin.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we need to also match against {ok, JSON} in order to be compatible with other libs

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will look at it

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,
Expand Down
2 changes: 1 addition & 1 deletion test/nova_error_controller_tests.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading