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

  1. 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
  1. 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

  1. 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
...
  1. 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
...
  1. 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
...
  1. 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
  1. 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

  1. 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}]}