Building a JSON API with Nova#
So far in this series we have been rendering HTML views with ErlyDTL templates. But what if we want to build a REST API that returns JSON? Nova makes this straightforward.
JSON handler#
Nova has a built-in JSON handler. Instead of returning {ok, Variables} from your controller (which renders a template), you return {json, Data} and Nova will encode it and set the correct content-type header.
Let’s create a new controller. In src/controllers create a file called my_first_nova_api_controller.erl:
-module(my_first_nova_api_controller).
-export([
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">>}
],
{json, #{users => Users}}.
show(#{bindings := #{<<"id">> := Id}}) ->
{json, #{id => binary_to_integer(Id), name => <<"Alice">>, email => <<"alice@example.com">>}};
show(_Req) ->
{status, 400, #{}, #{error => <<"missing id">>}}.
create(#{params := #{<<"name">> := Name, <<"email">> := Email}}) ->
{json, 201, #{}, #{id => 3, name => Name, email => Email}};
create(_Req) ->
{status, 422, #{}, #{error => <<"name and email required">>}}.Let’s look at what is happening here.
index/1 returns a list of users as JSON. The {json, Data} tuple tells Nova to encode the map as JSON and respond with status 200.
show/1 uses bindings from the request map. When we define a route with a path parameter like "/users/:id", Nova will put the matched value in the bindings map.
create/1 uses params to read the decoded request body. We return {json, 201, #{}, Data} which lets us set a custom status code. The third element is a map of extra headers if we need them.
Adding the routes#
Now let’s add the routes in our my_first_nova_router.erl. We will use a prefix to group our API routes:
-module(my_first_nova_router).
-behaviour(nova_router).
-export([
routes/1
]).
routes(_Environment) ->
[#{prefix => "",
security => false,
routes => [
{"/login", fun my_first_nova_main_controller:login/1, #{methods => [get]}},
{"/heartbeat", fun(_) -> {status, 200} end, #{methods => [get]}}
]
},
#{prefix => "",
security => fun my_first_nova_auth:username_password/1,
routes => [{"/", fun my_first_nova_main_controller:index/1, #{methods => [post]}}]
},
#{prefix => "/api",
security => false,
routes => [
{"/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]}}
]
}
].We added a new route map with prefix => "/api". All routes in this group will be prefixed with /api, so the full paths become /api/users and /api/users/:id.
Configuring JSON decoding#
For our POST endpoint we need Nova to decode the incoming JSON body. We update the plugin configuration in dev_sys.config.src:
{plugins, [
{pre_request, nova_request_plugin, #{
decode_json_body => true,
read_urlencoded_body => true
}}
]}With decode_json_body => true, the nova_request_plugin will decode incoming JSON bodies and put them in the params key of the request map. We keep read_urlencoded_body for our login form.
JSON library#
Nova uses thoas as the default JSON library. If you want to use a different library like jsx or jiffy, you can configure it in your application environment:
{my_first_nova, [
{json_lib, jsx}
]}The library module needs to export encode/1 and decode/1.
Testing our API#
Start the node and let’s test with curl:
$ rebar3 nova serveGet all users:
$ curl -s localhost:8080/api/users | python3 -m json.tool
{
"users": [
{
"id": 1,
"name": "Alice",
"email": "alice@example.com"
},
{
"id": 2,
"name": "Bob",
"email": "bob@example.com"
}
]
}Get a single user:
$ curl -s localhost:8080/api/users/1 | python3 -m json.tool
{
"id": 1,
"name": "Alice",
"email": "alice@example.com"
}Create a user:
$ curl -s -X POST localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{"name": "Charlie", "email": "charlie@example.com"}' | python3 -m json.tool
{
"id": 3,
"name": "Charlie",
"email": "charlie@example.com"
}Response formats#
Here is a summary of the different return tuples you can use for JSON responses:
%% Simple JSON response (200 for GET, 201 for POST)
{json, #{key => value}}
%% JSON with custom status code
{json, StatusCode, Headers, Body}
%% Status response (also encodes maps as JSON)
{status, StatusCode}
{status, StatusCode, Headers, Body}
%% Redirect
{redirect, "/some/path"}Adding custom headers#
If you need to add custom headers to your response, use the headers map:
index(_Req) ->
{json, 200, #{<<"x-request-id">> => <<"abc123">>}, #{users => []}}.In the next article we will look at WebSockets and how Nova handles real-time communication.