9. Add and Test the API - abarr/remote GitHub Wiki
Requirments
The requirements are basic for the endpoint
, a simple route to the root URL
(i.e. "/") that returns a JSON
payload. Example provided:
{
"timestamp":"2022-01-11T23:17:41.619379Z",
"users":[{"id":1,"points":12},{"id":2,"points":65}]
}
API Implementation
- Add a controller to handle
API
calls and a fallback controller for errors
"/lib/remote_web/controllers/user_controller.ex"
defmodule RemoteWeb.UserController do
@moduledoc """
Handles API calls to the root of the application
"""
use RemoteWeb, :controller
alias Remote.Users
action_fallback RemoteWeb.FallbackController
@doc """
Default call to some_domain.com returns a json payload
{
'users': [{id: 1, points: 30}, {id: 72, points: 30}],
'timestamp': `2020-07-30 17:09:33`
}
"""
def index(conn, _params) do
{:ok, payload} = Users.list_users()
render(conn, "index.json", payload: payload)
end
end
defmodule RemoteWeb.FallbackController do
@moduledoc """
Catches errors from API calls
"""
use RemoteWeb, :controller
def call(conn, {:error, msg}) do
conn
|> put_status(:error)
|> put_view(RemoteAWeb.ErrorView)
|> render("error.json", status: :error, message: msg)
end
end
- Add a view to handle the success and failure
"/lib/remote_web/views/user_view.ex"
defmodule RemoteWeb.UserView do
@moduledoc false
use RemoteWeb, :view
def render("index.json", %{payload: payload}) do
payload
end
end
"/lib/remote_web/views/error_view.ex"
defmodule RemoteWeb.ErrorView do
@moduledoc false
use RemoteWeb, :view
def render("error.json", %{msg: msg}) do
%{error: msg}
end
end
- This required a change to the
GenServer
to surface an error
"/lib/remote/users/user_server.ex"
# build results in common format
defp build_results(result_name, timestamp, func, args) when is_atom(result_name) do
case func.(args) do
users when is_list(users) ->
{:ok, %{result_name => users, timestamp: timestamp}}
_ ->
{:error, "User query failed!"}
end
end
...
- A quick update to stop a
Phoenix.Route
error
"/lib/remote_web/endpoint.ex"
...
plug Plug.Static,
at: "/",
from: :remote,
gzip: false
# only: ~w(assets fonts images favicon.ico robots.txt) <- COMMENTED
...
- I left
Dashboard
in the application, so I added a:signing_salt
"/config/config.exs"
...
config :remote, RemoteWeb.Endpoint,
url: [host: "localhost"],
render_errors: [view: RemoteWeb.ErrorView, accepts: ~w(json), layout: false],
pubsub_server: Remote.PubSub,
live_view: [signing_salt: "FslZr7qikzBEawDE"] # <- ADDED
...
- Finally, I updated the
router
"/lib/remote_web/router.ex"
defmodule RemoteWeb.Router do
use RemoteWeb, :router
pipeline :api do
plug :accepts, ["json"]
end
scope "/", RemoteWeb do
pipe_through :api
get "/", UserController, :index
end
if Mix.env() in [:dev, :test] do
import Phoenix.LiveDashboard.Router
scope "/" do
pipe_through [:fetch_session, :protect_from_forgery]
live_dashboard "/dashboard", metrics: RemoteWeb.Telemetry
end
end
end
- Testing the application
$ mix phi.server
Compiling 5 files (.ex)
Generated remote app
[info] Running RemoteWeb.Endpoint with cowboy 2.9.0 at 127.0.0.1:3000 (http)
[info] Access RemoteWeb.Endpoint at http://localhost:3000
[info] Elixir.Remote.Users.UserServer - successfully created
# on a new terminal
$ curl -v http://localhost:3000
* Trying ::1:3000...
* connect to ::1 port 3000 failed: Connection refused
* Trying 127.0.0.1:3000...
* Connected to localhost (127.0.0.1) port 3000 (#0)
> GET / HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.77.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< cache-control: max-age=0, private, must-revalidate
< content-length: 95
< content-type: application/json; charset=utf-8
< date: Wed, 12 Jan 2022 00:05:24 GMT
< server: Cowboy
< x-request-id: FsldHo_NzgCAbwAAAAPD
<
* Connection #0 to host localhost left intact
{"timestamp":"2022-01-12T00:05:17.257185Z","users":[{"id":1,"points":75},{"id":2,"points":66}]}⏎
API Testing
- Add a new test file
"/test/remote_web/controllers/user_api_test.exs"
defmodule RemoteWeb.UserApiTest do
use ExUnit.Case, async: false
use RemoteWeb.ConnCase, async: false
import Remote.UsersFixtures
describe "API Tests" do
@default_limit 2
setup do
seed_users(100, 100, 0)
:ok
end
test "call API", %{conn: conn} do
conn = get(conn, Routes.user_path(conn, :index))
list = json_response(conn, 200)["users"]
assert is_list(list)
assert not Enum.empty?(list)
end
test "test that the payload users list matches the default limit", %{conn: conn} do
conn = get(conn, Routes.user_path(conn, :index))
list = json_response(conn, 200)["users"]
assert Enum.count(list) == @default_limit
end
test "test that the timestamp is not nil", %{conn: conn} do
conn = get(conn, Routes.user_path(conn, :index))
assert json_response(conn, 200)["timestamp"] != nil
end
test "test that the timestamp changes between calls", %{conn: conn} do
conn = get(conn, Routes.user_path(conn, :index))
timestamp_1 = json_response(conn, 200)["timestamp"]
:timer.sleep(200)
conn = get(conn, Routes.user_path(conn, :index))
timestamp_2 = json_response(conn, 200)["timestamp"]
assert timestamp_1 != timestamp_2
end
end
end
This led me down a merry path of reading docs about the Ecto.Adapters.SQL.Sandbox
and testing collaborating processes (Something I have not done previously). In the end, I changed the tests to be async: false
to get them to run and pass. This is something I am going to have to experiment with so I fully understand why I could not get mode: shared
to work.
In addition to the ExUnit
tests I did some direct testing from the terminal.
# simple
$ curl localhost:3000
{"timestamp":"2022-01-12T02:27:23.466953Z","users":[{"id":1,"points":10},{"id":2,"points":57}]}
# Verbose
$ curl -v localhost:3000
* Trying ::1:3000...
* connect to ::1 port 3000 failed: Connection refused
* Trying 127.0.0.1:3000...
* Connected to localhost (127.0.0.1) port 3000 (#0)
> GET / HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.77.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< cache-control: max-age=0, private, must-revalidate
< content-length: 71
< content-type: application/json; charset=utf-8
< date: Wed, 12 Jan 2022 03:01:50 GMT
< server: Cowboy
< x-request-id: Fslmv02l4cAHh8QAAALj
<
* Connection #0 to host localhost left intact
{"timestamp":null,"users":[{"id":4,"points":97},{"id":12,"points":77}]}
# run a call every 10 second with interval set at 10 seconds
$ for run in {1..10}; do curl localhost:3000; sleep 10; done
{"timestamp":"2022-01-12T02:00:43.989857Z","users":[{"id":1,"points":37},{"id":2,"points":86}]}
{"timestamp":"2022-01-12T02:00:57.583700Z","users":[{"id":1,"points":25},{"id":2,"points":31}]}
{"timestamp":"2022-01-12T02:01:07.672149Z","users":[{"id":1,"points":64},{"id":2,"points":68}]}
{"timestamp":"2022-01-12T02:01:17.752000Z","users":[{"id":2,"points":79},{"id":3,"points":84}]}
{"timestamp":"2022-01-12T02:01:29.876280Z","users":[{"id":10,"points":97},{"id":36,"points":95}]}
{"timestamp":"2022-01-12T02:01:42.498487Z","users":[{"id":2,"points":98},{"id":3,"points":97}]}
{"timestamp":"2022-01-12T02:01:54.612189Z","users":[{"id":4,"points":69},{"id":6,"points":39}]}
{"timestamp":"2022-01-12T02:02:06.767965Z","users":[{"id":83,"points":98},{"id":182,"points":98}]}
{"timestamp":"2022-01-12T02:02:19.050259Z","users":[{"id":1,"points":31},{"id":2,"points":24}]}
{"timestamp":"2022-01-12T02:02:31.238188Z","users":[{"id":1,"points":35},{"id":2,"points":65}]}