From b14465687c0165723ef6ec9430e6785f4b6f339f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Wed, 11 Jan 2023 17:38:05 +0000 Subject: [PATCH 01/25] feat: Adding API router and ItemsController. #256 --- lib/app/item.ex | 1 + .../controllers/api/item_controller.ex | 93 +++++++++++++++++++ lib/app_web/router.ex | 15 ++- 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 lib/app_web/controllers/api/item_controller.ex diff --git a/lib/app/item.ex b/lib/app/item.ex index 79944197..71d6cb7b 100644 --- a/lib/app/item.ex +++ b/lib/app/item.ex @@ -6,6 +6,7 @@ defmodule App.Item do alias __MODULE__ require Logger + @derive {Jason.Encoder, only: [:id, :person_id, :status, :text]} schema "items" do field :person_id, :integer field :status, :integer diff --git a/lib/app_web/controllers/api/item_controller.ex b/lib/app_web/controllers/api/item_controller.ex new file mode 100644 index 00000000..3808c3cd --- /dev/null +++ b/lib/app_web/controllers/api/item_controller.ex @@ -0,0 +1,93 @@ +defmodule AppWeb.API.ItemController do + use AppWeb, :controller + alias App.Item + import Ecto.Changeset + + def show(conn, params) do + id = Map.get(params, "id") + + try do + item = Item.get_item!(id) + json(conn, item) + rescue + Ecto.NoResultsError -> + errors = %{ + code: 404, + message: "No item found with the given \'id\'.", + } + json(conn |> put_status(404), errors) + + Ecto.Query.CastError -> + errors = %{ + code: 400, + message: "The \'id\' is not an integer.", + } + json(conn |> put_status(400), errors) + end + end + + def create(conn, params) do + # Attributes to create item + # Person_id will be changed when auth is added + attrs = %{ + text: Map.get(params, "text"), + person_id: 0, + status: 2 + } + + case Item.create_item(attrs) do + + # Successfully creates item + {:ok, item} -> + id_item = Map.take(item, [:id]) + json(conn, id_item) + + # Error creating item + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + + def update(conn, params) do + id = Map.get(params, "id") + new_text = Map.get(params, "text") + + item = Item.get_item!(id) + + case Item.update_item(item, %{text: new_text}) do + + # Successfully updates item + {:ok, item} -> + json(conn, item) + + # Error creating item + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + + defp make_changeset_errors_readable(changeset) do + errors = %{ + code: 400, + message: "Malformed request", + } + + changeset_errors = traverse_errors(changeset, fn {msg, opts} -> + Regex.replace(~r"%{(\w+)}", msg, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + + Map.put(errors, :errors, changeset_errors) + end +end diff --git a/lib/app_web/router.ex b/lib/app_web/router.ex index 62b88847..968cbaf1 100644 --- a/lib/app_web/router.ex +++ b/lib/app_web/router.ex @@ -10,6 +10,10 @@ defmodule AppWeb.Router do plug :put_secure_browser_headers end + pipeline :api do + plug :accepts, ["json"] + end + # No Auth scope "/", AppWeb do pipe_through :browser @@ -17,7 +21,10 @@ defmodule AppWeb.Router do get "/login", AuthController, :login end - pipeline :authOptional, do: plug(AuthPlugOptional) + pipeline :authOptional do + plug :fetch_session + plug(AuthPlugOptional) + end scope "/", AppWeb do pipe_through [:browser, :authOptional] @@ -26,4 +33,10 @@ defmodule AppWeb.Router do live "/stats", StatsLive resources "/tags", TagController, except: [:show] end + + scope "/api", AppWeb do + pipe_through [:api, :authOptional] + + resources "/items", API.ItemController, only: [:create, :update, :show] + end end From d23b4c7d712bf3452353ad8d7d38a593bc57922b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Wed, 11 Jan 2023 18:35:21 +0000 Subject: [PATCH 02/25] feat: Adding timer API and TimerController. #256 --- lib/app/timer.ex | 31 ++++++ .../controllers/api/timer_controller.ex | 102 ++++++++++++++++++ lib/app_web/router.ex | 1 + 3 files changed, 134 insertions(+) create mode 100644 lib/app_web/controllers/api/timer_controller.ex diff --git a/lib/app/timer.ex b/lib/app/timer.ex index 4ce52433..26d0e8a2 100644 --- a/lib/app/timer.ex +++ b/lib/app/timer.ex @@ -7,6 +7,7 @@ defmodule App.Timer do alias __MODULE__ require Logger + @derive {Jason.Encoder, only: [:id, :start, :stop]} schema "timers" do field :start, :naive_datetime field :stop, :naive_datetime @@ -15,6 +16,17 @@ defmodule App.Timer do timestamps() end + defp validate_start_before_stop(changeset) do + start = get_field(changeset, :start) + stop = get_field(changeset, :stop) + + if NaiveDateTime.compare(start, stop) == :gt do + add_error(changeset, :start, "cannot be later than 'stop'") + else + changeset + end + end + @doc false def changeset(timer, attrs) do timer @@ -34,6 +46,24 @@ defmodule App.Timer do """ def get_timer!(id), do: Repo.get!(Timer, id) + @doc """ + Creates an `timer`. + + ## Examples + + iex> create_timer(%{item_id: 1, start: 2022-02-02T12:01:01}) + {:ok, %Timer{item_id: 1, start: 2022-02-02T12:01:01, stop: nil}} + + iex> create_timer(%{item_id: nil}) + {:error, %Ecto.Changeset{}} + + """ + def create_timer(attrs) do + %Timer{} + |> changeset(attrs) + |> Repo.insert() + end + @doc """ `start/1` starts a timer. @@ -76,6 +106,7 @@ defmodule App.Timer do def update_timer(attrs \\ %{}) do get_timer!(attrs.id) |> changeset(attrs) + |> validate_start_before_stop() |> Repo.update() end diff --git a/lib/app_web/controllers/api/timer_controller.ex b/lib/app_web/controllers/api/timer_controller.ex new file mode 100644 index 00000000..ea7c5987 --- /dev/null +++ b/lib/app_web/controllers/api/timer_controller.ex @@ -0,0 +1,102 @@ +defmodule AppWeb.API.TimerController do + use AppWeb, :controller + alias App.Timer + import Ecto.Changeset + + def index(conn, params) do + item_id = Map.get(params, "item_id") + + timers = Timer.list_timers_changesets(item_id) + json(conn, timers) + end + + def show(conn, params) do + id = Map.get(params, "id") + + try do + timer = Timer.get_timer!(id) + json(conn, timer) + rescue + Ecto.NoResultsError -> + errors = %{ + code: 404, + message: "No timer found with the given \'id\'.", + } + json(conn |> put_status(404), errors) + + Ecto.Query.CastError -> + errors = %{ + code: 400, + message: "The \'id\' is not an integer.", + } + json(conn |> put_status(400), errors) + end + end + + def create(conn, params) do + # Attributes to create timer + attrs = %{ + item_id: Map.get(params, "item_id"), + start: Map.get(params, "start"), + stop: Map.get(params, "stop") + } + + case Timer.create_timer(attrs) do + + # Successfully creates item + {:ok, timer} -> + id_timer = Map.take(timer, [:id]) + json(conn, id_timer) + + # Error creating item + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + + def update(conn, params) do + # Attributes to update timer + attrs_to_update = %{ + id: Map.get(params, "id"), + start: Map.get(params, "start"), + stop: Map.get(params, "stop") + } + + case Timer.update_timer(attrs_to_update) do + + # Successfully updates timer + {:ok, timer} -> + json(conn, timer) + + # Error creating timer + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + + + defp make_changeset_errors_readable(changeset) do + errors = %{ + code: 400, + message: "Malformed request", + } + + changeset_errors = traverse_errors(changeset, fn {msg, opts} -> + Regex.replace(~r"%{(\w+)}", msg, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + + Map.put(errors, :errors, changeset_errors) + end +end diff --git a/lib/app_web/router.ex b/lib/app_web/router.ex index 968cbaf1..c5458a52 100644 --- a/lib/app_web/router.ex +++ b/lib/app_web/router.ex @@ -38,5 +38,6 @@ defmodule AppWeb.Router do pipe_through [:api, :authOptional] resources "/items", API.ItemController, only: [:create, :update, :show] + resources "/items/:item_id/timers", API.TimerController, only: [:create, :update, :show, :index] end end From 3d5622358048a02277895c7378763b8028043263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Wed, 11 Jan 2023 22:06:36 +0000 Subject: [PATCH 03/25] feat: Adding tests for timer schema and item controller. #256 --- lib/app/timer.ex | 20 +---- .../controllers/api/item_controller.ex | 7 +- test/app/timer_test.exs | 22 ++++++ .../controllers/api/item_controller_test.exs | 75 +++++++++++++++++++ 4 files changed, 99 insertions(+), 25 deletions(-) create mode 100644 test/app_web/controllers/api/item_controller_test.exs diff --git a/lib/app/timer.ex b/lib/app/timer.ex index 26d0e8a2..64fa59a8 100644 --- a/lib/app/timer.ex +++ b/lib/app/timer.ex @@ -19,7 +19,7 @@ defmodule App.Timer do defp validate_start_before_stop(changeset) do start = get_field(changeset, :start) stop = get_field(changeset, :stop) - + if NaiveDateTime.compare(start, stop) == :gt do add_error(changeset, :start, "cannot be later than 'stop'") else @@ -46,24 +46,6 @@ defmodule App.Timer do """ def get_timer!(id), do: Repo.get!(Timer, id) - @doc """ - Creates an `timer`. - - ## Examples - - iex> create_timer(%{item_id: 1, start: 2022-02-02T12:01:01}) - {:ok, %Timer{item_id: 1, start: 2022-02-02T12:01:01, stop: nil}} - - iex> create_timer(%{item_id: nil}) - {:error, %Ecto.Changeset{}} - - """ - def create_timer(attrs) do - %Timer{} - |> changeset(attrs) - |> Repo.insert() - end - @doc """ `start/1` starts a timer. diff --git a/lib/app_web/controllers/api/item_controller.ex b/lib/app_web/controllers/api/item_controller.ex index 3808c3cd..4e54f6c4 100644 --- a/lib/app_web/controllers/api/item_controller.ex +++ b/lib/app_web/controllers/api/item_controller.ex @@ -82,12 +82,7 @@ defmodule AppWeb.API.ItemController do message: "Malformed request", } - changeset_errors = traverse_errors(changeset, fn {msg, opts} -> - Regex.replace(~r"%{(\w+)}", msg, fn _, key -> - opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() - end) - end) - + changeset_errors = traverse_errors(changeset, fn {msg, _opts} -> msg end) Map.put(errors, :errors, changeset_errors) end end diff --git a/test/app/timer_test.exs b/test/app/timer_test.exs index 021e0634..9fc7cb16 100644 --- a/test/app/timer_test.exs +++ b/test/app/timer_test.exs @@ -90,5 +90,27 @@ defmodule App.TimerTest do assert updated_timer.start == start assert updated_timer.stop == stop end + + test "update_timer(%{id: id, start: start, stop: stop}) with start later than stop should throw error" do + start = ~N[2022-10-27 05:00:00] + stop = ~N[2022-10-27 00:00:00] + + {:ok, item} = Item.create_item(@valid_item_attrs) + + {:ok, seven_seconds_ago} = + NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -7)) + + # Start the timer 7 seconds ago: + {:ok, timer} = + Timer.start(%{item_id: item.id, person_id: 1, start: seven_seconds_ago}) + + # Stop the timer based on its item_id + Timer.stop_timer_for_item_id(item.id) + + # Update timer with stop earlier than start + {:error, changeset} = Timer.update_timer(%{id: timer.id, start: start, stop: stop}) + + assert length(changeset.errors) > 0 + end end end diff --git a/test/app_web/controllers/api/item_controller_test.exs b/test/app_web/controllers/api/item_controller_test.exs new file mode 100644 index 00000000..e326f363 --- /dev/null +++ b/test/app_web/controllers/api/item_controller_test.exs @@ -0,0 +1,75 @@ +defmodule AppWeb.API.ItemControllerTest do + use AppWeb.ConnCase + alias App.Item + + @create_attrs %{person_id: 42, status: 0, text: "some text"} + @update_attrs %{person_id: 43, status: 0, text: "some updated text"} + @invalid_attrs %{person_id: nil, status: nil, text: nil} + + describe "show" do + test "specific item", %{conn: conn} do + {:ok, item} = Item.create_item(@create_attrs) + conn = get(conn, Routes.item_path(conn, :show, item.id)) + + assert conn.status == 200 + assert json_response(conn, 200)["id"] == item.id + assert json_response(conn, 200)["text"] == item.text + end + + test "not found item", %{conn: conn} do + conn = get(conn, Routes.item_path(conn, :show, -1)) + + assert conn.status == 404 + end + + test "invalid id (not being an integer)", %{conn: conn} do + conn = get(conn, Routes.item_path(conn, :show, "invalid")) + assert conn.status == 400 + end + end + + describe "create" do + test "a valid item", %{conn: conn} do + conn = post(conn, Routes.item_path(conn, :create, @create_attrs)) + + assert conn.status == 200 + assert json_response(conn, 200)["text"] == Map.get(@create_attrs, "text") + + assert json_response(conn, 200)["status"] == Map.get(@create_attrs, "status") + + assert json_response(conn, 200)["person_id"] == Map.get(@create_attrs, "person_id") + end + + test "an invalid item", %{conn: conn} do + conn = post(conn, Routes.item_path(conn, :create, @invalid_attrs)) + + assert conn.status == 400 + assert length(json_response(conn, 400)["errors"]["text"]) > 0 + end + end + + describe "update" do + test "item with valid attributes", %{conn: conn} do + {:ok, item} = Item.create_item(@create_attrs) + conn = put(conn, Routes.item_path(conn, :update, item.id, @update_attrs)) + + assert conn.status == 200 + assert json_response(conn, 200)["text"] == Map.get(@update_attrs, :text) + end + + test "item with invalid attributes", %{conn: conn} do + {:ok, item} = Item.create_item(@create_attrs) + conn = put(conn, Routes.item_path(conn, :update, item.id, @invalid_attrs)) + + assert conn.status == 400 + assert length(json_response(conn, 400)["errors"]["text"]) > 0 + end + end + + test "testing error traversal", %{conn: conn} do + {:ok, item} = Item.create_item(@create_attrs) + conn = put(conn, Routes.item_path(conn, :update, item.id, @invalid_attrs)) + + assert conn.status == 400 + end +end From 0355e5c4490e176667c9010ec73bc76d0f774c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Thu, 12 Jan 2023 11:28:31 +0000 Subject: [PATCH 04/25] feat: Timers and TimerController testing. #256 --- lib/app/timer.ex | 39 +++++- .../controllers/api/timer_controller.ex | 11 +- .../controllers/api/item_controller_test.exs | 7 -- .../controllers/api/timer_controller_test.exs | 115 ++++++++++++++++++ 4 files changed, 152 insertions(+), 20 deletions(-) create mode 100644 test/app_web/controllers/api/timer_controller_test.exs diff --git a/lib/app/timer.ex b/lib/app/timer.ex index 64fa59a8..3637f3b7 100644 --- a/lib/app/timer.ex +++ b/lib/app/timer.ex @@ -7,7 +7,7 @@ defmodule App.Timer do alias __MODULE__ require Logger - @derive {Jason.Encoder, only: [:id, :start, :stop]} + @derive {Jason.Encoder, only: [:id, :start, :stop, ]} schema "timers" do field :start, :naive_datetime field :stop, :naive_datetime @@ -20,10 +20,15 @@ defmodule App.Timer do start = get_field(changeset, :start) stop = get_field(changeset, :stop) - if NaiveDateTime.compare(start, stop) == :gt do - add_error(changeset, :start, "cannot be later than 'stop'") - else - changeset + # If start or stop is nil, no comparison occurs. + case is_nil(stop) or is_nil(start) do + true -> changeset + false -> + if NaiveDateTime.compare(start, stop) == :gt do + add_error(changeset, :start, "cannot be later than 'stop'") + else + changeset + end end end @@ -46,6 +51,30 @@ defmodule App.Timer do """ def get_timer!(id), do: Repo.get!(Timer, id) + @doc """ + `list_timers/1` lists all the timer objects of a given item `id`. + + ## Examples + + iex> list_timers(1) + [ + %App.Timer{ + id: 7, + start: ~N[2023-01-11 17:40:44], + stop: nil, + item_id: 1, + inserted_at: ~N[2023-01-11 18:01:43], + updated_at: ~N[2023-01-11 18:01:43] + } + ] + """ + def list_timers(item_id) do + Timer + |> where(item_id: ^item_id) + |> order_by(:id) + |> Repo.all() + end + @doc """ `start/1` starts a timer. diff --git a/lib/app_web/controllers/api/timer_controller.ex b/lib/app_web/controllers/api/timer_controller.ex index ea7c5987..310b171e 100644 --- a/lib/app_web/controllers/api/timer_controller.ex +++ b/lib/app_web/controllers/api/timer_controller.ex @@ -6,7 +6,7 @@ defmodule AppWeb.API.TimerController do def index(conn, params) do item_id = Map.get(params, "item_id") - timers = Timer.list_timers_changesets(item_id) + timers = Timer.list_timers(item_id) json(conn, timers) end @@ -41,7 +41,7 @@ defmodule AppWeb.API.TimerController do stop: Map.get(params, "stop") } - case Timer.create_timer(attrs) do + case Timer.start(attrs) do # Successfully creates item {:ok, timer} -> @@ -91,12 +91,7 @@ defmodule AppWeb.API.TimerController do message: "Malformed request", } - changeset_errors = traverse_errors(changeset, fn {msg, opts} -> - Regex.replace(~r"%{(\w+)}", msg, fn _, key -> - opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() - end) - end) - + changeset_errors = traverse_errors(changeset, fn {msg, _opts} -> msg end) Map.put(errors, :errors, changeset_errors) end end diff --git a/test/app_web/controllers/api/item_controller_test.exs b/test/app_web/controllers/api/item_controller_test.exs index e326f363..0defacdc 100644 --- a/test/app_web/controllers/api/item_controller_test.exs +++ b/test/app_web/controllers/api/item_controller_test.exs @@ -65,11 +65,4 @@ defmodule AppWeb.API.ItemControllerTest do assert length(json_response(conn, 400)["errors"]["text"]) > 0 end end - - test "testing error traversal", %{conn: conn} do - {:ok, item} = Item.create_item(@create_attrs) - conn = put(conn, Routes.item_path(conn, :update, item.id, @invalid_attrs)) - - assert conn.status == 400 - end end diff --git a/test/app_web/controllers/api/timer_controller_test.exs b/test/app_web/controllers/api/timer_controller_test.exs new file mode 100644 index 00000000..04e03321 --- /dev/null +++ b/test/app_web/controllers/api/timer_controller_test.exs @@ -0,0 +1,115 @@ +defmodule AppWeb.API.TimerControllerTest do + use AppWeb.ConnCase + alias App.Timer + alias App.Item + + @create_item_attrs %{person_id: 42, status: 0, text: "some text"} + + @create_attrs %{item_id: 42, start: "2022-10-27T00:00:00"} + @update_attrs %{item_id: 43, start: "2022-10-28T00:00:00"} + @invalid_attrs %{item_id: nil, start: nil} + + describe "index" do + test "timers", %{conn: conn} do + # Create item + {:ok, item} = Item.create_item(@create_item_attrs) + + # Create timer + started = NaiveDateTime.utc_now() + {:ok, timer} = Timer.start(%{item_id: item.id, start: started}) + + conn = get(conn, Routes.timer_path(conn, :index, item.id)) + + assert conn.status == 200 + assert length(json_response(conn, 200)) == 1 + end + end + + describe "show" do + test "specific timer", %{conn: conn} do + # Create item + {:ok, item} = Item.create_item(@create_item_attrs) + + # Create timer + started = NaiveDateTime.utc_now() + {:ok, timer} = Timer.start(%{item_id: item.id, start: started}) + + conn = get(conn, Routes.timer_path(conn, :show, item.id, timer.id)) + + assert conn.status == 200 + assert json_response(conn, 200)["id"] == timer.id + end + + test "not found timer", %{conn: conn} do + # Create item + {:ok, item} = Item.create_item(@create_item_attrs) + + conn = get(conn, Routes.timer_path(conn, :show, item.id, -1)) + + assert conn.status == 404 + end + + test "invalid id (not being an integer)", %{conn: conn} do + # Create item + {:ok, item} = Item.create_item(@create_item_attrs) + + conn = get(conn, Routes.timer_path(conn, :show, item.id, "invalid")) + assert conn.status == 400 + end + end + + describe "create" do + test "a valid timer", %{conn: conn} do + + # Create item + {:ok, item} = Item.create_item(@create_item_attrs) + + # Create timer + conn = post(conn, Routes.timer_path(conn, :create, item.id, @create_attrs)) + + assert conn.status == 200 + assert json_response(conn, 200)["start"] == Map.get(@create_attrs, "start") + end + + test "an invalid timer", %{conn: conn} do + # Create item + {:ok, item} = Item.create_item(@create_item_attrs) + + conn = post(conn, Routes.timer_path(conn, :create, item.id, @invalid_attrs)) + + assert conn.status == 400 + assert length(json_response(conn, 400)["errors"]["start"]) > 0 + end + end + + describe "update" do + test "timer with valid attributes", %{conn: conn} do + + # Create item + {:ok, item} = Item.create_item(@create_item_attrs) + + # Create timer + started = NaiveDateTime.utc_now() + {:ok, timer} = Timer.start(%{item_id: item.id, start: started}) + + conn = put(conn, Routes.timer_path(conn, :update, item.id, timer.id, @update_attrs)) + + assert conn.status == 200 + assert json_response(conn, 200)["start"] == Map.get(@update_attrs, :start) + end + + test "timer with invalid attributes", %{conn: conn} do + # Create item + {:ok, item} = Item.create_item(@create_item_attrs) + + # Create timer + started = NaiveDateTime.utc_now() + {:ok, timer} = Timer.start(%{item_id: item.id, start: started}) + + conn = put(conn, Routes.timer_path(conn, :update, item.id, timer.id, @invalid_attrs)) + + assert conn.status == 400 + assert length(json_response(conn, 400)["errors"]["start"]) > 0 + end + end +end From b6d10c7603626fe58f5c847f726a5e5c4f14940c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Thu, 12 Jan 2023 11:31:29 +0000 Subject: [PATCH 05/25] feat: Adding item and timer fixture when testing timer controller. #256 --- .../controllers/api/timer_controller_test.exs | 44 ++++++++----------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/test/app_web/controllers/api/timer_controller_test.exs b/test/app_web/controllers/api/timer_controller_test.exs index 04e03321..240df1c4 100644 --- a/test/app_web/controllers/api/timer_controller_test.exs +++ b/test/app_web/controllers/api/timer_controller_test.exs @@ -11,12 +11,8 @@ defmodule AppWeb.API.TimerControllerTest do describe "index" do test "timers", %{conn: conn} do - # Create item - {:ok, item} = Item.create_item(@create_item_attrs) - - # Create timer - started = NaiveDateTime.utc_now() - {:ok, timer} = Timer.start(%{item_id: item.id, start: started}) + # Create item and timer + {item, timer} = item_and_timer_fixture() conn = get(conn, Routes.timer_path(conn, :index, item.id)) @@ -27,12 +23,8 @@ defmodule AppWeb.API.TimerControllerTest do describe "show" do test "specific timer", %{conn: conn} do - # Create item - {:ok, item} = Item.create_item(@create_item_attrs) - - # Create timer - started = NaiveDateTime.utc_now() - {:ok, timer} = Timer.start(%{item_id: item.id, start: started}) + # Create item and timer + {item, timer} = item_and_timer_fixture() conn = get(conn, Routes.timer_path(conn, :show, item.id, timer.id)) @@ -84,13 +76,8 @@ defmodule AppWeb.API.TimerControllerTest do describe "update" do test "timer with valid attributes", %{conn: conn} do - - # Create item - {:ok, item} = Item.create_item(@create_item_attrs) - - # Create timer - started = NaiveDateTime.utc_now() - {:ok, timer} = Timer.start(%{item_id: item.id, start: started}) + # Create item and timer + {item, timer} = item_and_timer_fixture() conn = put(conn, Routes.timer_path(conn, :update, item.id, timer.id, @update_attrs)) @@ -99,12 +86,8 @@ defmodule AppWeb.API.TimerControllerTest do end test "timer with invalid attributes", %{conn: conn} do - # Create item - {:ok, item} = Item.create_item(@create_item_attrs) - - # Create timer - started = NaiveDateTime.utc_now() - {:ok, timer} = Timer.start(%{item_id: item.id, start: started}) + # Create item and timer + {item, timer} = item_and_timer_fixture() conn = put(conn, Routes.timer_path(conn, :update, item.id, timer.id, @invalid_attrs)) @@ -112,4 +95,15 @@ defmodule AppWeb.API.TimerControllerTest do assert length(json_response(conn, 400)["errors"]["start"]) > 0 end end + + defp item_and_timer_fixture() do + # Create item + {:ok, item} = Item.create_item(@create_item_attrs) + + # Create timer + started = NaiveDateTime.utc_now() + {:ok, timer} = Timer.start(%{item_id: item.id, start: started}) + + {item, timer} + end end From 600d123925955068066fdbc8c0c9053ddfdbe05f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Thu, 12 Jan 2023 16:28:04 +0000 Subject: [PATCH 06/25] feat: Creating API document. Adding validation when creating `timer`. #256 --- api.md | 653 ++++++++++++++++++++++++++++++++++++++++++ lib/app/timer.ex | 1 + lib/app_web/router.ex | 2 +- 3 files changed, 655 insertions(+), 1 deletion(-) create mode 100644 api.md diff --git a/api.md b/api.md new file mode 100644 index 00000000..5efc6ab2 --- /dev/null +++ b/api.md @@ -0,0 +1,653 @@ +
+ +# `REST`ful API integration + +
+ +This guide demonstrates +how to *extend* our MVP `Phoenix` application +so it also acts as an **API** +returning `JSON` data. + +We want our users to securely query +and manipulate their data +and want to ensure all actions +that are performed in the Web API +can also be done through our `RESTful` API +*and* `WebSocket API` +(for all real-time updates). + +Let's get cracking! 🎉 + +## 1. Add `/api` scope and pipeline + +We want all our API requests +to be made under the `/api` route. +This is easier for us to manage changes to API +that don't create unnecessary regressions to our liveviews. + +Let's start by opening `lib/router.ex` +and create a new `:api` pipeline +to be used under `scope "/api"`. + +```elixir + + pipeline :api do + plug :accepts, ["json"] + plug :fetch_session + end + + pipeline :authOptional do + plug(AuthPlugOptional) + end + + scope "/api", AppWeb do + pipe_through [:api, :authOptional] + + resources "/items", API.ItemController, only: [:create, :update, :show] + resources "/items/:item_id/timers", API.TimerController, only: [:create, :update, :show, :index] + end +``` + +We are creating an `:api` pipeline +that will only accepts and returns `json` objects. +`:fetch_session` is added as a plug +because `:authOptional` requires us to do so. + +Every request that is routed to `/api` +will be piped through both `:api` and `:authOptional` pipelines. + +You might have noticed two new controllers: +`API.ItemController` and `API.TimerController`. +We are going to need to create these to handle our requests! + +## 2. `ItemController` and `TimerController` + +Before creating our controller, let's define our requirements. We want the API to: + +- read contents of an `item`/`timer` +- list `timers` of an `item` +- create an `item` and return only the created `id`. +- edit an `item` + +We want each endpoint to respond appropriately if any data is invalid, +the response body and status should inform the user what went wrong. +We can leverage changesets to validate the `item` and `timer` +and check if it's correctly formatted. + +### 2.1 Adding tests + +Since we now know what to do, let's create our tests. + +Let's implement these controllers. +But before that, let's approach this +with a [`TDD mindset`](https://github.com/dwyl/learn-tdd) +and create our tests first! + +Create two new files: +- `test/app_web/api/item_controller_test.exs` +- `test/app_web/api/timer_controller_test.exs` + +Before implementing, +we recommend giving a look at [`learn-api-design`](https://github.com/dwyl/learn-api-design), +we are going to be using some tips coming from there! + +We want the API requests to be handled gracefully +when an error occurs. +The person using the API +[**should be shown meaningful errors**](https://github.com/dwyl/learn-api-design/blob/revamp/README.md#show-meaningful-errors). +Therefore, we need to test how our API behaves +when invalid attributes are requested +and/or an error occurs **and where**. + +Let's start with `item_controller_test.exs`. + +```elixir +defmodule AppWeb.API.ItemControllerTest do + use AppWeb.ConnCase + alias App.Item + + @create_attrs %{person_id: 42, status: 0, text: "some text"} + @update_attrs %{person_id: 43, status: 0, text: "some updated text"} + @invalid_attrs %{person_id: nil, status: nil, text: nil} + + describe "show" do + test "specific item", %{conn: conn} do + {:ok, item} = Item.create_item(@create_attrs) + conn = get(conn, Routes.item_path(conn, :show, item.id)) + + assert conn.status == 200 + assert json_response(conn, 200)["id"] == item.id + assert json_response(conn, 200)["text"] == item.text + end + + test "not found item", %{conn: conn} do + conn = get(conn, Routes.item_path(conn, :show, -1)) + + assert conn.status == 404 + end + + test "invalid id (not being an integer)", %{conn: conn} do + conn = get(conn, Routes.item_path(conn, :show, "invalid")) + assert conn.status == 400 + end + end + + describe "create" do + test "a valid item", %{conn: conn} do + conn = post(conn, Routes.item_path(conn, :create, @create_attrs)) + + assert conn.status == 200 + assert json_response(conn, 200)["text"] == Map.get(@create_attrs, "text") + + assert json_response(conn, 200)["status"] == Map.get(@create_attrs, "status") + + assert json_response(conn, 200)["person_id"] == Map.get(@create_attrs, "person_id") + end + + test "an invalid item", %{conn: conn} do + conn = post(conn, Routes.item_path(conn, :create, @invalid_attrs)) + + assert conn.status == 400 + assert length(json_response(conn, 400)["errors"]["text"]) > 0 + end + end + + describe "update" do + test "item with valid attributes", %{conn: conn} do + {:ok, item} = Item.create_item(@create_attrs) + conn = put(conn, Routes.item_path(conn, :update, item.id, @update_attrs)) + + assert conn.status == 200 + assert json_response(conn, 200)["text"] == Map.get(@update_attrs, :text) + end + + test "item with invalid attributes", %{conn: conn} do + {:ok, item} = Item.create_item(@create_attrs) + conn = put(conn, Routes.item_path(conn, :update, item.id, @invalid_attrs)) + + assert conn.status == 400 + assert length(json_response(conn, 400)["errors"]["text"]) > 0 + end + end +end +``` + +In `/item`, users will be able to +**create**, **update** or **query a single item**. +In each test we are testing +successful scenarios (the [Happy Path](https://en.wikipedia.org/wiki/Happy_path)), +alongside situations where the person +requests non-existent items +or tries to create new ones with invalid attributes. + +The same scenario occurs in `test/app_web/api/timer_controller_test.exs`. + +```elixir +defmodule AppWeb.API.TimerControllerTest do + use AppWeb.ConnCase + alias App.Timer + alias App.Item + + @create_item_attrs %{person_id: 42, status: 0, text: "some text"} + + @create_attrs %{item_id: 42, start: "2022-10-27T00:00:00"} + @update_attrs %{item_id: 43, start: "2022-10-28T00:00:00"} + @invalid_attrs %{item_id: nil, start: nil} + + describe "index" do + test "timers", %{conn: conn} do + # Create item and timer + {item, timer} = item_and_timer_fixture() + + conn = get(conn, Routes.timer_path(conn, :index, item.id)) + + assert conn.status == 200 + assert length(json_response(conn, 200)) == 1 + end + end + + describe "show" do + test "specific timer", %{conn: conn} do + # Create item and timer + {item, timer} = item_and_timer_fixture() + + conn = get(conn, Routes.timer_path(conn, :show, item.id, timer.id)) + + assert conn.status == 200 + assert json_response(conn, 200)["id"] == timer.id + end + + test "not found timer", %{conn: conn} do + # Create item + {:ok, item} = Item.create_item(@create_item_attrs) + + conn = get(conn, Routes.timer_path(conn, :show, item.id, -1)) + + assert conn.status == 404 + end + + test "invalid id (not being an integer)", %{conn: conn} do + # Create item + {:ok, item} = Item.create_item(@create_item_attrs) + + conn = get(conn, Routes.timer_path(conn, :show, item.id, "invalid")) + assert conn.status == 400 + end + end + + describe "create" do + test "a valid timer", %{conn: conn} do + + # Create item + {:ok, item} = Item.create_item(@create_item_attrs) + + # Create timer + conn = post(conn, Routes.timer_path(conn, :create, item.id, @create_attrs)) + + assert conn.status == 200 + assert json_response(conn, 200)["start"] == Map.get(@create_attrs, "start") + end + + test "an invalid timer", %{conn: conn} do + # Create item + {:ok, item} = Item.create_item(@create_item_attrs) + + conn = post(conn, Routes.timer_path(conn, :create, item.id, @invalid_attrs)) + + assert conn.status == 400 + assert length(json_response(conn, 400)["errors"]["start"]) > 0 + end + end + + describe "update" do + test "timer with valid attributes", %{conn: conn} do + # Create item and timer + {item, timer} = item_and_timer_fixture() + + conn = put(conn, Routes.timer_path(conn, :update, item.id, timer.id, @update_attrs)) + + assert conn.status == 200 + assert json_response(conn, 200)["start"] == Map.get(@update_attrs, :start) + end + + test "timer with invalid attributes", %{conn: conn} do + # Create item and timer + {item, timer} = item_and_timer_fixture() + + conn = put(conn, Routes.timer_path(conn, :update, item.id, timer.id, @invalid_attrs)) + + assert conn.status == 400 + assert length(json_response(conn, 400)["errors"]["start"]) > 0 + end + end + + defp item_and_timer_fixture() do + # Create item + {:ok, item} = Item.create_item(@create_item_attrs) + + # Create timer + started = NaiveDateTime.utc_now() + {:ok, timer} = Timer.start(%{item_id: item.id, start: started}) + + {item, timer} + end +end +``` + +If you run `mix test`, they will fail, +because these functions aren't defined. + +### 2.2 Implementing the controllers + +It's time to implement our sweet controllers! +Let's start with `ItemController`. + +Create a directory inside `lib/app_web/controllers/api` +and a file inside called `item_controller.ex`. +Paste the following code. + +```elixir +defmodule AppWeb.API.ItemController do + use AppWeb, :controller + alias App.Item + import Ecto.Changeset + + def show(conn, params) do + id = Map.get(params, "id") + + try do + item = Item.get_item!(id) + json(conn, item) + rescue + Ecto.NoResultsError -> + errors = %{ + code: 404, + message: "No item found with the given \'id\'.", + } + json(conn |> put_status(404), errors) + + Ecto.Query.CastError -> + errors = %{ + code: 400, + message: "The \'id\' is not an integer.", + } + json(conn |> put_status(400), errors) + end + end + + def create(conn, params) do + # Attributes to create item + # Person_id will be changed when auth is added + attrs = %{ + text: Map.get(params, "text"), + person_id: 0, + status: 2 + } + + case Item.create_item(attrs) do + + # Successfully creates item + {:ok, item} -> + id_item = Map.take(item, [:id]) + json(conn, id_item) + + # Error creating item + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + + def update(conn, params) do + id = Map.get(params, "id") + new_text = Map.get(params, "text") + + item = Item.get_item!(id) + + case Item.update_item(item, %{text: new_text}) do + + # Successfully updates item + {:ok, item} -> + json(conn, item) + + # Error creating item + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + + defp make_changeset_errors_readable(changeset) do + errors = %{ + code: 400, + message: "Malformed request", + } + + changeset_errors = traverse_errors(changeset, fn {msg, _opts} -> msg end) + Map.put(errors, :errors, changeset_errors) + end +end +``` + +Each function should be self-explanatory. + +- `:show` pertains to `GET api/items/:id` +and returns an `item` object. +- `:create` refers to `POST api/items/:id` +and yields the `id` of the newly created `item` object. +- `:update` refers to `PUT or PATCH api/items/:id` +and returns the updated `item` object. + +In cases where, for example, +`:id` is invalid when creating an `item`; +or there are missing fields when creating an `item`, +an error message is displayed in which fields +the changeset validation yielded errors. +To display errors meaningfully, +we *traverse the errors* in the changeset +inside the `make_changeset_errors_readable/1` function. + +For example, +if you try to make a `POST` request +to `api/items` with the following body: + +```json +{ + "invalid": "31231" +} +``` + +The API wil return a `400 Bad Request` HTTP status code +with the following message, +since it was expecting a `text` field: + +```json +{ + "code": 400, + "errors": { + "text": [ + "can't be blank" + ] + }, + "message": "Malformed request" +} +``` + +To retrieve/update/create an `item`, +we are simply calling the schema functions +defined in `lib/app/timer.ex`. + +Let's head over and create our `TimerController`! +Inside the same directory, create `timer_controller.ex` +and use this code. + +```elixir +defmodule AppWeb.API.TimerController do + use AppWeb, :controller + alias App.Timer + import Ecto.Changeset + + def index(conn, params) do + item_id = Map.get(params, "item_id") + + timers = Timer.list_timers(item_id) + json(conn, timers) + end + + def show(conn, params) do + id = Map.get(params, "id") + + try do + timer = Timer.get_timer!(id) + json(conn, timer) + rescue + Ecto.NoResultsError -> + errors = %{ + code: 404, + message: "No timer found with the given \'id\'.", + } + json(conn |> put_status(404), errors) + + Ecto.Query.CastError -> + errors = %{ + code: 400, + message: "The \'id\' is not an integer.", + } + json(conn |> put_status(400), errors) + end + end + + def create(conn, params) do + # Attributes to create timer + attrs = %{ + item_id: Map.get(params, "item_id"), + start: Map.get(params, "start"), + stop: Map.get(params, "stop") + } + + case Timer.start(attrs) do + + # Successfully creates item + {:ok, timer} -> + id_timer = Map.take(timer, [:id]) + json(conn, id_timer) + + # Error creating item + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + + def update(conn, params) do + # Attributes to update timer + attrs_to_update = %{ + id: Map.get(params, "id"), + start: Map.get(params, "start"), + stop: Map.get(params, "stop") + } + + case Timer.update_timer(attrs_to_update) do + + # Successfully updates timer + {:ok, timer} -> + json(conn, timer) + + # Error creating timer + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + + + defp make_changeset_errors_readable(changeset) do + errors = %{ + code: 400, + message: "Malformed request", + } + + changeset_errors = traverse_errors(changeset, fn {msg, _opts} -> msg end) + Map.put(errors, :errors, changeset_errors) + end +end +``` + +The same set of conditions and operations +occur in `timer_controller.ex`. +The only difference is that there's an extra operation: +the person can list the `timers` for a specific `item`. +For this, we are using a function +that *is not yet implemented* +in `lib/app/timer.ex` - **`list_timers/1`**. + +## 3. Listing `timers` and validating updates + +Let's implement `list_timers/1` +in `lib/app/timer.ex`. + +```elixir + def list_timers(item_id) do + Timer + |> where(item_id: ^item_id) + |> order_by(:id) + |> Repo.all() + end +``` + +Simple, isn't it? +We are just retrieving every `timer` object +of a given `item.id`. + +Digressing, +when updating or creating a `timer`, +we want to make sure the `stop` field +is not *before* `start`, +as it simply wouldn't make sense! +To fix this (and give the person using the API +an error explaining in case this happens), +we will create our own +[**changeset datetime validator**](https://hexdocs.pm/ecto/Ecto.Changeset.html#module-validations-and-constraints). + +We will going to validate the two dates +being passed and check if `stop > start`. +Inside `lib/app/timer.ex`, +add the following private function. + +```elixir + defp validate_start_before_stop(changeset) do + start = get_field(changeset, :start) + stop = get_field(changeset, :stop) + + # If start or stop is nil, no comparison occurs. + case is_nil(stop) or is_nil(start) do + true -> changeset + false -> + if NaiveDateTime.compare(start, stop) == :gt do + add_error(changeset, :start, "cannot be later than 'stop'") + else + changeset + end + end + end +``` + +If `stop` or `start` is nil, we can't compare the datetimes, +so we just skip the validation. +This usually happens when creating a timer that is ongoing +(`stop` is nil). +We won't block creating `timers` with `stop` with a `nil` value. + +Now let's use this validator! +Pipe `start/1` and `update_timer/1` +with our newly created validator, +like so. + +```elixir + def start(attrs \\ %{}) do + %Timer{} + |> changeset(attrs) + |> validate_start_before_stop() + |> Repo.insert() + end + + def update_timer(attrs \\ %{}) do + get_timer!(attrs.id) + |> changeset(attrs) + |> validate_start_before_stop() + |> Repo.update() + end +``` + +If you try to create a `timer` +where `start` is *after* `stop`, +it will error out! + +error_datetimes + + +# And you should be done! + +This document is going to be expanded +as development continues. +So if you're reading this, it's because that's all we currently have! + +If you found it interesting, +please let us know by starring the repo on GitHub! ⭐ \ No newline at end of file diff --git a/lib/app/timer.ex b/lib/app/timer.ex index 3637f3b7..33109081 100644 --- a/lib/app/timer.ex +++ b/lib/app/timer.ex @@ -87,6 +87,7 @@ defmodule App.Timer do def start(attrs \\ %{}) do %Timer{} |> changeset(attrs) + |> validate_start_before_stop() |> Repo.insert() end diff --git a/lib/app_web/router.ex b/lib/app_web/router.ex index c5458a52..ff58f9b7 100644 --- a/lib/app_web/router.ex +++ b/lib/app_web/router.ex @@ -12,6 +12,7 @@ defmodule AppWeb.Router do pipeline :api do plug :accepts, ["json"] + plug :fetch_session end # No Auth @@ -22,7 +23,6 @@ defmodule AppWeb.Router do end pipeline :authOptional do - plug :fetch_session plug(AuthPlugOptional) end From 3ec039c2518654d34636870bd7e65034c7b432b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Thu, 12 Jan 2023 16:38:25 +0000 Subject: [PATCH 07/25] fix: Mix format. Fixing typo in API document. #256 --- api.md | 9 +++----- lib/app/timer.ex | 6 +++-- .../controllers/api/item_controller.ex | 10 ++++---- .../controllers/api/timer_controller.ex | 11 ++++----- lib/app_web/router.ex | 4 +++- test/app/timer_test.exs | 3 ++- .../controllers/api/item_controller_test.exs | 6 +++-- .../controllers/api/timer_controller_test.exs | 23 ++++++++++++++----- 8 files changed, 43 insertions(+), 29 deletions(-) diff --git a/api.md b/api.md index 5efc6ab2..8e8de9dd 100644 --- a/api.md +++ b/api.md @@ -77,10 +77,7 @@ and check if it's correctly formatted. ### 2.1 Adding tests -Since we now know what to do, let's create our tests. - -Let's implement these controllers. -But before that, let's approach this +Let's approach this with a [`TDD mindset`](https://github.com/dwyl/learn-tdd) and create our tests first! @@ -609,10 +606,10 @@ add the following private function. end ``` -If `stop` or `start` is nil, we can't compare the datetimes, +If `stop` or `start` is `nil`, we can't compare the datetimes, so we just skip the validation. This usually happens when creating a timer that is ongoing -(`stop` is nil). +(`stop` is `nil`). We won't block creating `timers` with `stop` with a `nil` value. Now let's use this validator! diff --git a/lib/app/timer.ex b/lib/app/timer.ex index 33109081..bd36760e 100644 --- a/lib/app/timer.ex +++ b/lib/app/timer.ex @@ -7,7 +7,7 @@ defmodule App.Timer do alias __MODULE__ require Logger - @derive {Jason.Encoder, only: [:id, :start, :stop, ]} + @derive {Jason.Encoder, only: [:id, :start, :stop]} schema "timers" do field :start, :naive_datetime field :stop, :naive_datetime @@ -22,7 +22,9 @@ defmodule App.Timer do # If start or stop is nil, no comparison occurs. case is_nil(stop) or is_nil(start) do - true -> changeset + true -> + changeset + false -> if NaiveDateTime.compare(start, stop) == :gt do add_error(changeset, :start, "cannot be later than 'stop'") diff --git a/lib/app_web/controllers/api/item_controller.ex b/lib/app_web/controllers/api/item_controller.ex index 4e54f6c4..3b4994ff 100644 --- a/lib/app_web/controllers/api/item_controller.ex +++ b/lib/app_web/controllers/api/item_controller.ex @@ -13,15 +13,17 @@ defmodule AppWeb.API.ItemController do Ecto.NoResultsError -> errors = %{ code: 404, - message: "No item found with the given \'id\'.", + message: "No item found with the given \'id\'." } + json(conn |> put_status(404), errors) Ecto.Query.CastError -> errors = %{ code: 400, - message: "The \'id\' is not an integer.", + message: "The \'id\' is not an integer." } + json(conn |> put_status(400), errors) end end @@ -36,7 +38,6 @@ defmodule AppWeb.API.ItemController do } case Item.create_item(attrs) do - # Successfully creates item {:ok, item} -> id_item = Map.take(item, [:id]) @@ -60,7 +61,6 @@ defmodule AppWeb.API.ItemController do item = Item.get_item!(id) case Item.update_item(item, %{text: new_text}) do - # Successfully updates item {:ok, item} -> json(conn, item) @@ -79,7 +79,7 @@ defmodule AppWeb.API.ItemController do defp make_changeset_errors_readable(changeset) do errors = %{ code: 400, - message: "Malformed request", + message: "Malformed request" } changeset_errors = traverse_errors(changeset, fn {msg, _opts} -> msg end) diff --git a/lib/app_web/controllers/api/timer_controller.ex b/lib/app_web/controllers/api/timer_controller.ex index 310b171e..3fff2786 100644 --- a/lib/app_web/controllers/api/timer_controller.ex +++ b/lib/app_web/controllers/api/timer_controller.ex @@ -20,15 +20,17 @@ defmodule AppWeb.API.TimerController do Ecto.NoResultsError -> errors = %{ code: 404, - message: "No timer found with the given \'id\'.", + message: "No timer found with the given \'id\'." } + json(conn |> put_status(404), errors) Ecto.Query.CastError -> errors = %{ code: 400, - message: "The \'id\' is not an integer.", + message: "The \'id\' is not an integer." } + json(conn |> put_status(400), errors) end end @@ -42,7 +44,6 @@ defmodule AppWeb.API.TimerController do } case Timer.start(attrs) do - # Successfully creates item {:ok, timer} -> id_timer = Map.take(timer, [:id]) @@ -68,7 +69,6 @@ defmodule AppWeb.API.TimerController do } case Timer.update_timer(attrs_to_update) do - # Successfully updates timer {:ok, timer} -> json(conn, timer) @@ -84,11 +84,10 @@ defmodule AppWeb.API.TimerController do end end - defp make_changeset_errors_readable(changeset) do errors = %{ code: 400, - message: "Malformed request", + message: "Malformed request" } changeset_errors = traverse_errors(changeset, fn {msg, _opts} -> msg end) diff --git a/lib/app_web/router.ex b/lib/app_web/router.ex index ff58f9b7..e8bbfaac 100644 --- a/lib/app_web/router.ex +++ b/lib/app_web/router.ex @@ -38,6 +38,8 @@ defmodule AppWeb.Router do pipe_through [:api, :authOptional] resources "/items", API.ItemController, only: [:create, :update, :show] - resources "/items/:item_id/timers", API.TimerController, only: [:create, :update, :show, :index] + + resources "/items/:item_id/timers", API.TimerController, + only: [:create, :update, :show, :index] end end diff --git a/test/app/timer_test.exs b/test/app/timer_test.exs index 9fc7cb16..5d3a202a 100644 --- a/test/app/timer_test.exs +++ b/test/app/timer_test.exs @@ -108,7 +108,8 @@ defmodule App.TimerTest do Timer.stop_timer_for_item_id(item.id) # Update timer with stop earlier than start - {:error, changeset} = Timer.update_timer(%{id: timer.id, start: start, stop: stop}) + {:error, changeset} = + Timer.update_timer(%{id: timer.id, start: start, stop: stop}) assert length(changeset.errors) > 0 end diff --git a/test/app_web/controllers/api/item_controller_test.exs b/test/app_web/controllers/api/item_controller_test.exs index 0defacdc..fded3848 100644 --- a/test/app_web/controllers/api/item_controller_test.exs +++ b/test/app_web/controllers/api/item_controller_test.exs @@ -35,9 +35,11 @@ defmodule AppWeb.API.ItemControllerTest do assert conn.status == 200 assert json_response(conn, 200)["text"] == Map.get(@create_attrs, "text") - assert json_response(conn, 200)["status"] == Map.get(@create_attrs, "status") + assert json_response(conn, 200)["status"] == + Map.get(@create_attrs, "status") - assert json_response(conn, 200)["person_id"] == Map.get(@create_attrs, "person_id") + assert json_response(conn, 200)["person_id"] == + Map.get(@create_attrs, "person_id") end test "an invalid item", %{conn: conn} do diff --git a/test/app_web/controllers/api/timer_controller_test.exs b/test/app_web/controllers/api/timer_controller_test.exs index 240df1c4..b38d1bc3 100644 --- a/test/app_web/controllers/api/timer_controller_test.exs +++ b/test/app_web/controllers/api/timer_controller_test.exs @@ -52,22 +52,25 @@ defmodule AppWeb.API.TimerControllerTest do describe "create" do test "a valid timer", %{conn: conn} do - # Create item {:ok, item} = Item.create_item(@create_item_attrs) # Create timer - conn = post(conn, Routes.timer_path(conn, :create, item.id, @create_attrs)) + conn = + post(conn, Routes.timer_path(conn, :create, item.id, @create_attrs)) assert conn.status == 200 - assert json_response(conn, 200)["start"] == Map.get(@create_attrs, "start") + + assert json_response(conn, 200)["start"] == + Map.get(@create_attrs, "start") end test "an invalid timer", %{conn: conn} do # Create item {:ok, item} = Item.create_item(@create_item_attrs) - conn = post(conn, Routes.timer_path(conn, :create, item.id, @invalid_attrs)) + conn = + post(conn, Routes.timer_path(conn, :create, item.id, @invalid_attrs)) assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["start"]) > 0 @@ -79,7 +82,11 @@ defmodule AppWeb.API.TimerControllerTest do # Create item and timer {item, timer} = item_and_timer_fixture() - conn = put(conn, Routes.timer_path(conn, :update, item.id, timer.id, @update_attrs)) + conn = + put( + conn, + Routes.timer_path(conn, :update, item.id, timer.id, @update_attrs) + ) assert conn.status == 200 assert json_response(conn, 200)["start"] == Map.get(@update_attrs, :start) @@ -89,7 +96,11 @@ defmodule AppWeb.API.TimerControllerTest do # Create item and timer {item, timer} = item_and_timer_fixture() - conn = put(conn, Routes.timer_path(conn, :update, item.id, timer.id, @invalid_attrs)) + conn = + put( + conn, + Routes.timer_path(conn, :update, item.id, timer.id, @invalid_attrs) + ) assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["start"]) > 0 From 519f16c2a01de8dd15dc7d80b4076923673cb406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Thu, 12 Jan 2023 17:30:24 +0000 Subject: [PATCH 08/25] fix: Adding schema Jason encoder section in API document. #256 --- api.md | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/api.md b/api.md index 8e8de9dd..188c8c39 100644 --- a/api.md +++ b/api.md @@ -555,7 +555,72 @@ For this, we are using a function that *is not yet implemented* in `lib/app/timer.ex` - **`list_timers/1`**. -## 3. Listing `timers` and validating updates + +## 3. `JSON` serializing + +Let's look at `index` in `lib/app/timer.ex`. +You may notice that we are returning a `JSON` +with `json(conn, timers)`. + +```elixir + def index(conn, params) do + item_id = Map.get(params, "item_id") + + timers = Timer.list_timers(item_id) + json(conn, timers) + end +``` + +However, as it stands, +[`Jason`](https://github.com/michalmuskala/jason) +(which is the package that serializes and deserializes `JSON` objects), +doesn't know how to encode/decode our `timer` and `item` objects. + +We can **derive the implementation** +by specifying which fields should be encoded to `JSON`. +We are going to be using `Jason.Encoder` for this. + +In `lib/app/timer.ex`, +add the line on top of the schema, like so. + +```elixir + @derive {Jason.Encoder, only: [:id, :start, :stop]} + schema "timers" do + field :start, :naive_datetime + field :stop, :naive_datetime + belongs_to :item, Item, references: :id, foreign_key: :item_id + + timestamps() + end +``` + +This will allow `Jason` to encode +any `Timer` struct when returning API calls. + +Let's do the same for `Item`! +In `lib/app/timer.ex`, + +```elixir + @derive {Jason.Encoder, only: [:id, :person_id, :status, :text]} + schema "items" do + field :person_id, :integer + field :status, :integer + field :text, :string + + has_many :timer, Timer + many_to_many(:tags, Tag, join_through: ItemTag, on_replace: :delete) + + timestamps() + end +``` + +By leveraging the `@derive` annotation, +we can easily encode our structs +and serialize them as `JSON` objects +so they can be returned to the person +using the API! ✨ + +## 4. Listing `timers` and validating updates Let's implement `list_timers/1` in `lib/app/timer.ex`. From 0abf8af3f95b341fa5e04ff4c93e7259e322de7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Thu, 12 Jan 2023 18:47:35 +0000 Subject: [PATCH 09/25] fix: Removing try/rescue. #256 --- api.md | 102 ++++++++++++------ lib/app/item.ex | 21 +++- lib/app/timer.ex | 17 ++- .../controllers/api/item_controller.ex | 34 +++--- .../controllers/api/timer_controller.ex | 34 +++--- 5 files changed, 143 insertions(+), 65 deletions(-) diff --git a/api.md b/api.md index 188c8c39..2d1b003f 100644 --- a/api.md +++ b/api.md @@ -310,25 +310,29 @@ defmodule AppWeb.API.ItemController do alias App.Item import Ecto.Changeset - def show(conn, params) do - id = Map.get(params, "id") - - try do - item = Item.get_item!(id) - json(conn, item) - rescue - Ecto.NoResultsError -> - errors = %{ - code: 404, - message: "No item found with the given \'id\'.", - } - json(conn |> put_status(404), errors) + def show(conn, %{"id" => id} = _params) do + case Integer.parse(id) do + # ID is an integer + {id, _float} -> + case Item.get_item(id) do + nil -> + errors = %{ + code: 404, + message: "No item found with the given \'id\'." + } + json(conn |> put_status(404), errors) + + timer -> + json(conn, timer) + end - Ecto.Query.CastError -> + # ID is not an integer + :error -> errors = %{ code: 400, - message: "The \'id\' is not an integer.", + message: "The \'id\' is not an integer." } + json(conn |> put_status(400), errors) end end @@ -460,25 +464,29 @@ defmodule AppWeb.API.TimerController do json(conn, timers) end - def show(conn, params) do - id = Map.get(params, "id") - - try do - timer = Timer.get_timer!(id) - json(conn, timer) - rescue - Ecto.NoResultsError -> - errors = %{ - code: 404, - message: "No timer found with the given \'id\'.", - } - json(conn |> put_status(404), errors) + def show(conn, %{"id" => id} = _params) do + case Integer.parse(id) do + # ID is an integer + {id, _float} -> + case Timer.get_timer(id) do + nil -> + errors = %{ + code: 404, + message: "No timer found with the given \'id\'." + } + json(conn |> put_status(404), errors) + + timer -> + json(conn, timer) + end - Ecto.Query.CastError -> + # ID is not an integer + :error -> errors = %{ code: 400, - message: "The \'id\' is not an integer.", + message: "The \'id\' is not an integer." } + json(conn |> put_status(400), errors) end end @@ -620,7 +628,7 @@ and serialize them as `JSON` objects so they can be returned to the person using the API! ✨ -## 4. Listing `timers` and validating updates +## 4. Listing `timers` and `items` and validating updates Let's implement `list_timers/1` in `lib/app/timer.ex`. @@ -638,6 +646,38 @@ Simple, isn't it? We are just retrieving every `timer` object of a given `item.id`. +We are also using `Item.get_item/1` +and `Timer.get_timer/1`, +instead of using +[bang (!) functions](https://stackoverflow.com/questions/33324302/what-are-elixir-bang-functions). +We are not using bang functions +because they throw Exceptions. +And using `try/rescue` constructs +[isn't a good practice.](https://elixir-lang.org/getting-started/try-catch-and-rescue.html) + +To validate parameters and return errors, +we need to be able to "catch" these scenarios. +Therefore, we create non-bang functions +that don't raise exceptions. + +In `app/lib/timer.ex`, +add `get_timer/1`. + +```elixir + def get_timer(id), do: Repo.get(Timer, id) +``` + +In `app/lib/item.ex`, +add `get_item/1`. + +```elixir + def get_item(id) do + Item + |> Repo.get(id) + |> Repo.preload(tags: from(t in Tag, order_by: t.text)) + end +``` + Digressing, when updating or creating a `timer`, we want to make sure the `stop` field diff --git a/lib/app/item.ex b/lib/app/item.ex index 71d6cb7b..3b3a22b8 100644 --- a/lib/app/item.ex +++ b/lib/app/item.ex @@ -55,7 +55,7 @@ defmodule App.Item do end @doc """ - Gets a single item. + `get_item!/1` gets a single Item. Raises `Ecto.NoResultsError` if the Item does not exist. @@ -74,6 +74,25 @@ defmodule App.Item do |> Repo.preload(tags: from(t in Tag, order_by: t.text)) end + @doc """ + `get_item/1` gets a single Item. + + Returns nil if the Item does not exist + + ## Examples + + iex> get_item(1) + %Timer{} + + iex> get_item(1313) + nil + """ + def get_item(id) do + Item + |> Repo.get(id) + |> Repo.preload(tags: from(t in Tag, order_by: t.text)) + end + @doc """ Returns the list of items where the status is different to "deleted" diff --git a/lib/app/timer.ex b/lib/app/timer.ex index bd36760e..bbd7a95b 100644 --- a/lib/app/timer.ex +++ b/lib/app/timer.ex @@ -42,7 +42,7 @@ defmodule App.Timer do end @doc """ - `get_timer/1` gets a single Timer. + `get_timer!/1` gets a single Timer. Raises `Ecto.NoResultsError` if the Timer does not exist. @@ -53,6 +53,21 @@ defmodule App.Timer do """ def get_timer!(id), do: Repo.get!(Timer, id) + @doc """ + `get_timer/1` gets a single Timer. + + Returns nil if the Timer does not exist + + ## Examples + + iex> get_timer(1) + %Timer{} + + iex> get_item!(13131) + nil + """ + def get_timer(id), do: Repo.get(Timer, id) + @doc """ `list_timers/1` lists all the timer objects of a given item `id`. diff --git a/lib/app_web/controllers/api/item_controller.ex b/lib/app_web/controllers/api/item_controller.ex index 3b4994ff..1ab2c0f4 100644 --- a/lib/app_web/controllers/api/item_controller.ex +++ b/lib/app_web/controllers/api/item_controller.ex @@ -3,22 +3,24 @@ defmodule AppWeb.API.ItemController do alias App.Item import Ecto.Changeset - def show(conn, params) do - id = Map.get(params, "id") - - try do - item = Item.get_item!(id) - json(conn, item) - rescue - Ecto.NoResultsError -> - errors = %{ - code: 404, - message: "No item found with the given \'id\'." - } - - json(conn |> put_status(404), errors) - - Ecto.Query.CastError -> + def show(conn, %{"id" => id} = _params) do + case Integer.parse(id) do + # ID is an integer + {id, _float} -> + case Item.get_item(id) do + nil -> + errors = %{ + code: 404, + message: "No item found with the given \'id\'." + } + json(conn |> put_status(404), errors) + + timer -> + json(conn, timer) + end + + # ID is not an integer + :error -> errors = %{ code: 400, message: "The \'id\' is not an integer." diff --git a/lib/app_web/controllers/api/timer_controller.ex b/lib/app_web/controllers/api/timer_controller.ex index 3fff2786..8de31491 100644 --- a/lib/app_web/controllers/api/timer_controller.ex +++ b/lib/app_web/controllers/api/timer_controller.ex @@ -10,22 +10,24 @@ defmodule AppWeb.API.TimerController do json(conn, timers) end - def show(conn, params) do - id = Map.get(params, "id") - - try do - timer = Timer.get_timer!(id) - json(conn, timer) - rescue - Ecto.NoResultsError -> - errors = %{ - code: 404, - message: "No timer found with the given \'id\'." - } - - json(conn |> put_status(404), errors) - - Ecto.Query.CastError -> + def show(conn, %{"id" => id} = _params) do + case Integer.parse(id) do + # ID is an integer + {id, _float} -> + case Timer.get_timer(id) do + nil -> + errors = %{ + code: 404, + message: "No timer found with the given \'id\'." + } + json(conn |> put_status(404), errors) + + timer -> + json(conn, timer) + end + + # ID is not an integer + :error -> errors = %{ code: 400, message: "The \'id\' is not an integer." From 2edb2b76c66f4da7faba84f501fc39c257de7509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Thu, 12 Jan 2023 18:52:25 +0000 Subject: [PATCH 10/25] fix: Mix format. #256 --- lib/app/item.ex | 18 +++++++++--------- lib/app_web/controllers/api/item_controller.ex | 1 + .../controllers/api/timer_controller.ex | 1 + 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/lib/app/item.ex b/lib/app/item.ex index 3b3a22b8..d93bb888 100644 --- a/lib/app/item.ex +++ b/lib/app/item.ex @@ -74,19 +74,19 @@ defmodule App.Item do |> Repo.preload(tags: from(t in Tag, order_by: t.text)) end - @doc """ - `get_item/1` gets a single Item. + @doc """ + `get_item/1` gets a single Item. - Returns nil if the Item does not exist + Returns nil if the Item does not exist - ## Examples + ## Examples - iex> get_item(1) - %Timer{} + iex> get_item(1) + %Timer{} - iex> get_item(1313) - nil - """ + iex> get_item(1313) + nil + """ def get_item(id) do Item |> Repo.get(id) diff --git a/lib/app_web/controllers/api/item_controller.ex b/lib/app_web/controllers/api/item_controller.ex index 1ab2c0f4..d9f2862f 100644 --- a/lib/app_web/controllers/api/item_controller.ex +++ b/lib/app_web/controllers/api/item_controller.ex @@ -13,6 +13,7 @@ defmodule AppWeb.API.ItemController do code: 404, message: "No item found with the given \'id\'." } + json(conn |> put_status(404), errors) timer -> diff --git a/lib/app_web/controllers/api/timer_controller.ex b/lib/app_web/controllers/api/timer_controller.ex index 8de31491..c7c2163f 100644 --- a/lib/app_web/controllers/api/timer_controller.ex +++ b/lib/app_web/controllers/api/timer_controller.ex @@ -20,6 +20,7 @@ defmodule AppWeb.API.TimerController do code: 404, message: "No timer found with the given \'id\'." } + json(conn |> put_status(404), errors) timer -> From 115b3e6dcd093a45d813fe40de282932d6f1c711 Mon Sep 17 00:00:00 2001 From: nelsonic Date: Thu, 12 Jan 2023 22:19:03 +0000 Subject: [PATCH 11/25] merge main --- lib/app/timer.ex | 6 ++---- mix.exs | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/app/timer.ex b/lib/app/timer.ex index bbd7a95b..dfb0965e 100644 --- a/lib/app/timer.ex +++ b/lib/app/timer.ex @@ -1,9 +1,7 @@ defmodule App.Timer do use Ecto.Schema - import Ecto.Changeset - alias App.Item - import Ecto.Query - alias App.Repo + import Ecto.{Changeset, Query} + alias App.{Item, Repo} alias __MODULE__ require Logger diff --git a/mix.exs b/mix.exs index 969482d7..b4b99537 100644 --- a/mix.exs +++ b/mix.exs @@ -69,8 +69,7 @@ defmodule App.MixProject do {:fields, "~> 2.10.3"}, # Useful functions: github.com/dwyl/useful - {:useful, "~> 1.0.8"}, - + {:useful, "~> 1.0.8", override: true}, # See: github.com/dwyl/useful/issues/17 {:atomic_map, "~> 0.9.3"}, @@ -79,8 +78,7 @@ defmodule App.MixProject do # create docs on localhost by running "mix docs" {:ex_doc, "~> 0.29", only: :dev, runtime: false}, - - # Track test coverage + # Track test coverage: github.com/parroty/excoveralls {:excoveralls, "~> 0.15", only: [:test, :dev]}, # git pre-commit hook runs tests before allowing commits From 8729c6a9cc78f73ccfa0323b60f231d79ed51315 Mon Sep 17 00:00:00 2001 From: nelsonic Date: Thu, 12 Jan 2023 22:22:37 +0000 Subject: [PATCH 12/25] redirect to auth (not full url) --- test/app_web/live/app_live_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/app_web/live/app_live_test.exs b/test/app_web/live/app_live_test.exs index 5e74a23e..e5bfd3d4 100644 --- a/test/app_web/live/app_live_test.exs +++ b/test/app_web/live/app_live_test.exs @@ -643,10 +643,10 @@ defmodule AppWeb.AppLiveTest do assert "/" = redirected_to(conn, 302) end - test "test login link redirect to auth.dwyl.com", %{conn: conn} do + test "test login link redirect to auth", %{conn: conn} do conn = get(conn, "/login") - assert redirected_to(conn, 302) =~ "auth.dwyl.com" + assert redirected_to(conn, 302) =~ "auth" end test "tags_to_string/1" do From fe061d1a455ae1240a2267fd7882cd03b9f8c678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Fri, 13 Jan 2023 11:19:18 +0000 Subject: [PATCH 13/25] fix: Fixing tests and API with Papertrail integration. #256 --- api.md | 70 +++++++++++++------ .../controllers/api/item_controller.ex | 8 +-- test/app/timer_test.exs | 2 +- .../controllers/api/item_controller_test.exs | 6 +- .../controllers/api/timer_controller_test.exs | 10 +-- 5 files changed, 60 insertions(+), 36 deletions(-) diff --git a/api.md b/api.md index 2d1b003f..fdf7753d 100644 --- a/api.md +++ b/api.md @@ -110,7 +110,7 @@ defmodule AppWeb.API.ItemControllerTest do describe "show" do test "specific item", %{conn: conn} do - {:ok, item} = Item.create_item(@create_attrs) + {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) conn = get(conn, Routes.item_path(conn, :show, item.id)) assert conn.status == 200 @@ -137,9 +137,11 @@ defmodule AppWeb.API.ItemControllerTest do assert conn.status == 200 assert json_response(conn, 200)["text"] == Map.get(@create_attrs, "text") - assert json_response(conn, 200)["status"] == Map.get(@create_attrs, "status") + assert json_response(conn, 200)["status"] == + Map.get(@create_attrs, "status") - assert json_response(conn, 200)["person_id"] == Map.get(@create_attrs, "person_id") + assert json_response(conn, 200)["person_id"] == + Map.get(@create_attrs, "person_id") end test "an invalid item", %{conn: conn} do @@ -152,7 +154,7 @@ defmodule AppWeb.API.ItemControllerTest do describe "update" do test "item with valid attributes", %{conn: conn} do - {:ok, item} = Item.create_item(@create_attrs) + {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) conn = put(conn, Routes.item_path(conn, :update, item.id, @update_attrs)) assert conn.status == 200 @@ -160,7 +162,7 @@ defmodule AppWeb.API.ItemControllerTest do end test "item with invalid attributes", %{conn: conn} do - {:ok, item} = Item.create_item(@create_attrs) + {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) conn = put(conn, Routes.item_path(conn, :update, item.id, @invalid_attrs)) assert conn.status == 400 @@ -217,7 +219,7 @@ defmodule AppWeb.API.TimerControllerTest do test "not found timer", %{conn: conn} do # Create item - {:ok, item} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) conn = get(conn, Routes.timer_path(conn, :show, item.id, -1)) @@ -226,7 +228,7 @@ defmodule AppWeb.API.TimerControllerTest do test "invalid id (not being an integer)", %{conn: conn} do # Create item - {:ok, item} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) conn = get(conn, Routes.timer_path(conn, :show, item.id, "invalid")) assert conn.status == 400 @@ -235,22 +237,25 @@ defmodule AppWeb.API.TimerControllerTest do describe "create" do test "a valid timer", %{conn: conn} do - # Create item - {:ok, item} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) # Create timer - conn = post(conn, Routes.timer_path(conn, :create, item.id, @create_attrs)) + conn = + post(conn, Routes.timer_path(conn, :create, item.id, @create_attrs)) assert conn.status == 200 - assert json_response(conn, 200)["start"] == Map.get(@create_attrs, "start") + + assert json_response(conn, 200)["start"] == + Map.get(@create_attrs, "start") end test "an invalid timer", %{conn: conn} do # Create item - {:ok, item} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) - conn = post(conn, Routes.timer_path(conn, :create, item.id, @invalid_attrs)) + conn = + post(conn, Routes.timer_path(conn, :create, item.id, @invalid_attrs)) assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["start"]) > 0 @@ -262,7 +267,11 @@ defmodule AppWeb.API.TimerControllerTest do # Create item and timer {item, timer} = item_and_timer_fixture() - conn = put(conn, Routes.timer_path(conn, :update, item.id, timer.id, @update_attrs)) + conn = + put( + conn, + Routes.timer_path(conn, :update, item.id, timer.id, @update_attrs) + ) assert conn.status == 200 assert json_response(conn, 200)["start"] == Map.get(@update_attrs, :start) @@ -272,7 +281,11 @@ defmodule AppWeb.API.TimerControllerTest do # Create item and timer {item, timer} = item_and_timer_fixture() - conn = put(conn, Routes.timer_path(conn, :update, item.id, timer.id, @invalid_attrs)) + conn = + put( + conn, + Routes.timer_path(conn, :update, item.id, timer.id, @invalid_attrs) + ) assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["start"]) > 0 @@ -281,7 +294,7 @@ defmodule AppWeb.API.TimerControllerTest do defp item_and_timer_fixture() do # Create item - {:ok, item} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) # Create timer started = NaiveDateTime.utc_now() @@ -320,10 +333,11 @@ defmodule AppWeb.API.ItemController do code: 404, message: "No item found with the given \'id\'." } + json(conn |> put_status(404), errors) - timer -> - json(conn, timer) + item -> + json(conn, item) end # ID is not an integer @@ -347,9 +361,8 @@ defmodule AppWeb.API.ItemController do } case Item.create_item(attrs) do - # Successfully creates item - {:ok, item} -> + {:ok, %{model: item, version: _version}} -> id_item = Map.take(item, [:id]) json(conn, id_item) @@ -371,9 +384,8 @@ defmodule AppWeb.API.ItemController do item = Item.get_item!(id) case Item.update_item(item, %{text: new_text}) do - # Successfully updates item - {:ok, item} -> + {:ok, %{model: item, version: _version}} -> json(conn, item) # Error creating item @@ -390,7 +402,7 @@ defmodule AppWeb.API.ItemController do defp make_changeset_errors_readable(changeset) do errors = %{ code: 400, - message: "Malformed request", + message: "Malformed request" } changeset_errors = traverse_errors(changeset, fn {msg, _opts} -> msg end) @@ -408,6 +420,18 @@ and yields the `id` of the newly created `item` object. - `:update` refers to `PUT or PATCH api/items/:id` and returns the updated `item` object. +Do notice that, since we are using +[`PaperTrail`](https://github.com/izelnakri/paper_trail) +to record changes to the `items`, +database operations return +a map with `"model"` and `"version"`, +hence why we are pattern-matching it when +updating and create items. + +```elixir +{:ok, %{model: item, version: _version}} -> Item.create_item(attrs) +``` + In cases where, for example, `:id` is invalid when creating an `item`; or there are missing fields when creating an `item`, diff --git a/lib/app_web/controllers/api/item_controller.ex b/lib/app_web/controllers/api/item_controller.ex index d9f2862f..c271d1d3 100644 --- a/lib/app_web/controllers/api/item_controller.ex +++ b/lib/app_web/controllers/api/item_controller.ex @@ -16,8 +16,8 @@ defmodule AppWeb.API.ItemController do json(conn |> put_status(404), errors) - timer -> - json(conn, timer) + item -> + json(conn, item) end # ID is not an integer @@ -42,7 +42,7 @@ defmodule AppWeb.API.ItemController do case Item.create_item(attrs) do # Successfully creates item - {:ok, item} -> + {:ok, %{model: item, version: _version}} -> id_item = Map.take(item, [:id]) json(conn, id_item) @@ -65,7 +65,7 @@ defmodule AppWeb.API.ItemController do case Item.update_item(item, %{text: new_text}) do # Successfully updates item - {:ok, item} -> + {:ok, %{model: item, version: _version}} -> json(conn, item) # Error creating item diff --git a/test/app/timer_test.exs b/test/app/timer_test.exs index 1b39ec69..da320baa 100644 --- a/test/app/timer_test.exs +++ b/test/app/timer_test.exs @@ -101,7 +101,7 @@ defmodule App.TimerTest do start = ~N[2022-10-27 05:00:00] stop = ~N[2022-10-27 00:00:00] - {:ok, item} = Item.create_item(@valid_item_attrs) + {:ok, %{model: item, version: _version}} = Item.create_item(@valid_item_attrs) {:ok, seven_seconds_ago} = NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -7)) diff --git a/test/app_web/controllers/api/item_controller_test.exs b/test/app_web/controllers/api/item_controller_test.exs index fded3848..5dcf0eb5 100644 --- a/test/app_web/controllers/api/item_controller_test.exs +++ b/test/app_web/controllers/api/item_controller_test.exs @@ -8,7 +8,7 @@ defmodule AppWeb.API.ItemControllerTest do describe "show" do test "specific item", %{conn: conn} do - {:ok, item} = Item.create_item(@create_attrs) + {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) conn = get(conn, Routes.item_path(conn, :show, item.id)) assert conn.status == 200 @@ -52,7 +52,7 @@ defmodule AppWeb.API.ItemControllerTest do describe "update" do test "item with valid attributes", %{conn: conn} do - {:ok, item} = Item.create_item(@create_attrs) + {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) conn = put(conn, Routes.item_path(conn, :update, item.id, @update_attrs)) assert conn.status == 200 @@ -60,7 +60,7 @@ defmodule AppWeb.API.ItemControllerTest do end test "item with invalid attributes", %{conn: conn} do - {:ok, item} = Item.create_item(@create_attrs) + {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) conn = put(conn, Routes.item_path(conn, :update, item.id, @invalid_attrs)) assert conn.status == 400 diff --git a/test/app_web/controllers/api/timer_controller_test.exs b/test/app_web/controllers/api/timer_controller_test.exs index b38d1bc3..40397351 100644 --- a/test/app_web/controllers/api/timer_controller_test.exs +++ b/test/app_web/controllers/api/timer_controller_test.exs @@ -34,7 +34,7 @@ defmodule AppWeb.API.TimerControllerTest do test "not found timer", %{conn: conn} do # Create item - {:ok, item} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) conn = get(conn, Routes.timer_path(conn, :show, item.id, -1)) @@ -43,7 +43,7 @@ defmodule AppWeb.API.TimerControllerTest do test "invalid id (not being an integer)", %{conn: conn} do # Create item - {:ok, item} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) conn = get(conn, Routes.timer_path(conn, :show, item.id, "invalid")) assert conn.status == 400 @@ -53,7 +53,7 @@ defmodule AppWeb.API.TimerControllerTest do describe "create" do test "a valid timer", %{conn: conn} do # Create item - {:ok, item} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) # Create timer conn = @@ -67,7 +67,7 @@ defmodule AppWeb.API.TimerControllerTest do test "an invalid timer", %{conn: conn} do # Create item - {:ok, item} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) conn = post(conn, Routes.timer_path(conn, :create, item.id, @invalid_attrs)) @@ -109,7 +109,7 @@ defmodule AppWeb.API.TimerControllerTest do defp item_and_timer_fixture() do # Create item - {:ok, item} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) # Create timer started = NaiveDateTime.utc_now() From 43954a1fe768ae0a24bfb184fb7edde61b6f2a8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Fri, 13 Jan 2023 11:20:53 +0000 Subject: [PATCH 14/25] fix: Mix format. #256 --- test/app/timer_test.exs | 3 ++- .../controllers/api/timer_controller_test.exs | 15 ++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/test/app/timer_test.exs b/test/app/timer_test.exs index da320baa..f6baaad3 100644 --- a/test/app/timer_test.exs +++ b/test/app/timer_test.exs @@ -101,7 +101,8 @@ defmodule App.TimerTest do start = ~N[2022-10-27 05:00:00] stop = ~N[2022-10-27 00:00:00] - {:ok, %{model: item, version: _version}} = Item.create_item(@valid_item_attrs) + {:ok, %{model: item, version: _version}} = + Item.create_item(@valid_item_attrs) {:ok, seven_seconds_ago} = NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -7)) diff --git a/test/app_web/controllers/api/timer_controller_test.exs b/test/app_web/controllers/api/timer_controller_test.exs index 40397351..fcc53d56 100644 --- a/test/app_web/controllers/api/timer_controller_test.exs +++ b/test/app_web/controllers/api/timer_controller_test.exs @@ -34,7 +34,8 @@ defmodule AppWeb.API.TimerControllerTest do test "not found timer", %{conn: conn} do # Create item - {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) conn = get(conn, Routes.timer_path(conn, :show, item.id, -1)) @@ -43,7 +44,8 @@ defmodule AppWeb.API.TimerControllerTest do test "invalid id (not being an integer)", %{conn: conn} do # Create item - {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) conn = get(conn, Routes.timer_path(conn, :show, item.id, "invalid")) assert conn.status == 400 @@ -53,7 +55,8 @@ defmodule AppWeb.API.TimerControllerTest do describe "create" do test "a valid timer", %{conn: conn} do # Create item - {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) # Create timer conn = @@ -67,7 +70,8 @@ defmodule AppWeb.API.TimerControllerTest do test "an invalid timer", %{conn: conn} do # Create item - {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) conn = post(conn, Routes.timer_path(conn, :create, item.id, @invalid_attrs)) @@ -109,7 +113,8 @@ defmodule AppWeb.API.TimerControllerTest do defp item_and_timer_fixture() do # Create item - {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) # Create timer started = NaiveDateTime.utc_now() From 0d2d36933203661192c2d83439b550211a2e6412 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Fri, 13 Jan 2023 11:44:50 +0000 Subject: [PATCH 15/25] feat: Mentioning API in BUILDIT.md. #256 --- BUILDIT.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/BUILDIT.md b/BUILDIT.md index 45b9134b..5f31d873 100644 --- a/BUILDIT.md +++ b/BUILDIT.md @@ -46,6 +46,10 @@ We suggest keeping two terminal tabs/windows running one for the server `mix phx.server` and the other for the tests. That way you can also see the UI as you progress. +We have created a *separate* +document detailing the implementation of the API. +You can find it in [`api.md`](./api.md). + With that in place, let's get building! - [Build Log 👩‍💻](#build-log-) From 94941b8e54820ecaa5e2ec772b2272590bdc78e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Fri, 13 Jan 2023 18:10:17 +0000 Subject: [PATCH 16/25] fix: Item controller update handles gracefully when ID not found. #256 --- .../controllers/api/item_controller.ex | 35 ++++++++++++------- .../controllers/api/item_controller_test.exs | 6 ++++ 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/lib/app_web/controllers/api/item_controller.ex b/lib/app_web/controllers/api/item_controller.ex index c271d1d3..fe366003 100644 --- a/lib/app_web/controllers/api/item_controller.ex +++ b/lib/app_web/controllers/api/item_controller.ex @@ -61,21 +61,32 @@ defmodule AppWeb.API.ItemController do id = Map.get(params, "id") new_text = Map.get(params, "text") - item = Item.get_item!(id) + # Get tag with the ID + case Item.get_item(id) do + nil -> + errors = %{ + code: 404, + message: "No item found with the given \'id\'." + } - case Item.update_item(item, %{text: new_text}) do - # Successfully updates item - {:ok, %{model: item, version: _version}} -> - json(conn, item) + json(conn |> put_status(404), errors) - # Error creating item - {:error, %Ecto.Changeset{} = changeset} -> - errors = make_changeset_errors_readable(changeset) + # If item is found, try to update it + item -> + case Item.update_item(item, %{text: new_text}) do + # Successfully updates item + {:ok, %{model: item, version: _version}} -> + json(conn, item) - json( - conn |> put_status(400), - errors - ) + # Error creating item + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end end end diff --git a/test/app_web/controllers/api/item_controller_test.exs b/test/app_web/controllers/api/item_controller_test.exs index 5dcf0eb5..1e48a083 100644 --- a/test/app_web/controllers/api/item_controller_test.exs +++ b/test/app_web/controllers/api/item_controller_test.exs @@ -66,5 +66,11 @@ defmodule AppWeb.API.ItemControllerTest do assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["text"]) > 0 end + + test "item that doesn't exist", %{conn: conn} do + conn = put(conn, Routes.item_path(conn, :update, -1, @invalid_attrs)) + + assert conn.status == 404 + end end end From 7555cb41e49231650217881c5b8db6e48e63b8a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Fri, 13 Jan 2023 18:38:53 +0000 Subject: [PATCH 17/25] fix: Fixing updating timer when no ID is found. #256 Had to fix the liveview as well because `update_timer` didn't take a Timer prior. This change was made so it was consistent with the rest of the API and to show the error the the user of the API if the ID was not found. --- lib/app/timer.ex | 10 +++-- .../controllers/api/item_controller.ex | 2 +- .../controllers/api/timer_controller.ex | 40 +++++++++++++------ test/app/timer_test.exs | 4 +- .../controllers/api/timer_controller_test.exs | 6 +++ 5 files changed, 42 insertions(+), 20 deletions(-) diff --git a/lib/app/timer.ex b/lib/app/timer.ex index dfb0965e..87a16a4e 100644 --- a/lib/app/timer.ex +++ b/lib/app/timer.ex @@ -130,8 +130,8 @@ defmodule App.Timer do {:ok, %Timer{id: 1, start: ~N[2022-07-11 05:15:31], stop: ~N[2022-07-11 05:15:37}} """ - def update_timer(attrs \\ %{}) do - get_timer!(attrs.id) + def update_timer(%Timer{} = timer, attrs \\ %{}) do + timer |> changeset(attrs) |> validate_start_before_stop() |> Repo.update() @@ -177,7 +177,8 @@ defmodule App.Timer do case NaiveDateTime.compare(start, max_end) do :gt -> - update_timer(%{id: timer_id, start: start, stop: nil}) + timer = get_timer(timer_id) + update_timer(timer, %{start: start, stop: nil}) {:ok, []} _ -> @@ -276,7 +277,8 @@ defmodule App.Timer do end end - update_timer(%{id: timer_id, start: start, stop: stop}) + timer = get_timer(timer_id) + update_timer(timer, %{start: start, stop: stop}) {:ok, []} :eq -> diff --git a/lib/app_web/controllers/api/item_controller.ex b/lib/app_web/controllers/api/item_controller.ex index fe366003..7711885c 100644 --- a/lib/app_web/controllers/api/item_controller.ex +++ b/lib/app_web/controllers/api/item_controller.ex @@ -61,7 +61,7 @@ defmodule AppWeb.API.ItemController do id = Map.get(params, "id") new_text = Map.get(params, "text") - # Get tag with the ID + # Get item with the ID case Item.get_item(id) do nil -> errors = %{ diff --git a/lib/app_web/controllers/api/timer_controller.ex b/lib/app_web/controllers/api/timer_controller.ex index c7c2163f..17010a1a 100644 --- a/lib/app_web/controllers/api/timer_controller.ex +++ b/lib/app_web/controllers/api/timer_controller.ex @@ -64,27 +64,41 @@ defmodule AppWeb.API.TimerController do end def update(conn, params) do + + id = Map.get(params, "id") + # Attributes to update timer attrs_to_update = %{ - id: Map.get(params, "id"), start: Map.get(params, "start"), stop: Map.get(params, "stop") } - case Timer.update_timer(attrs_to_update) do - # Successfully updates timer - {:ok, timer} -> - json(conn, timer) + case Timer.get_timer(id) do + nil -> + errors = %{ + code: 404, + message: "No timer found with the given \'id\'." + } - # Error creating timer - {:error, %Ecto.Changeset{} = changeset} -> - errors = make_changeset_errors_readable(changeset) + json(conn |> put_status(404), errors) - json( - conn |> put_status(400), - errors - ) - end + # If timer is found, try to update it + timer -> + case Timer.update_timer(timer, attrs_to_update) do + # Successfully updates timer + {:ok, timer} -> + json(conn, timer) + + # Error creating timer + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end end defp make_changeset_errors_readable(changeset) do diff --git a/test/app/timer_test.exs b/test/app/timer_test.exs index f6baaad3..b842bdf0 100644 --- a/test/app/timer_test.exs +++ b/test/app/timer_test.exs @@ -89,7 +89,7 @@ defmodule App.TimerTest do Timer.stop_timer_for_item_id(item.id) # Update timer to specific datetimes - Timer.update_timer(%{id: timer.id, start: start, stop: stop}) + Timer.update_timer(timer, %{start: start, stop: stop}) updated_timer = Timer.get_timer!(timer.id) @@ -116,7 +116,7 @@ defmodule App.TimerTest do # Update timer with stop earlier than start {:error, changeset} = - Timer.update_timer(%{id: timer.id, start: start, stop: stop}) + Timer.update_timer(timer, %{start: start, stop: stop}) assert length(changeset.errors) > 0 end diff --git a/test/app_web/controllers/api/timer_controller_test.exs b/test/app_web/controllers/api/timer_controller_test.exs index fcc53d56..f8a9b425 100644 --- a/test/app_web/controllers/api/timer_controller_test.exs +++ b/test/app_web/controllers/api/timer_controller_test.exs @@ -109,6 +109,12 @@ defmodule AppWeb.API.TimerControllerTest do assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["start"]) > 0 end + + test "timer that doesn't exist", %{conn: conn} do + conn = put(conn, Routes.timer_path(conn, :update, -1, -1, @invalid_attrs)) + + assert conn.status == 404 + end end defp item_and_timer_fixture() do From 819db653c18db0ea191421ffb7d21e17d0b262ca Mon Sep 17 00:00:00 2001 From: nelsonic Date: Sun, 15 Jan 2023 07:16:25 +0000 Subject: [PATCH 18/25] remove redundant status code assertions in API tests #256 --- api.md | 52 +++++++++---------- .../controllers/api/item_controller_test.exs | 5 -- 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/api.md b/api.md index fdf7753d..ac5dff45 100644 --- a/api.md +++ b/api.md @@ -1,19 +1,19 @@
-# `REST`ful API integration +# `REST`ful `API` Integration
This guide demonstrates -how to *extend* our MVP `Phoenix` application -so it also acts as an **API** +how to *extend* our MVP `Phoenix` App +so it also acts as an **`API`** returning `JSON` data. -We want our users to securely query -and manipulate their data -and want to ensure all actions -that are performed in the Web API -can also be done through our `RESTful` API +`people` want to securely query +and update their data. +We want to ensure all actions +that are performed in the Web UI +can also be done through our `REST API` *and* `WebSocket API` (for all real-time updates). @@ -21,14 +21,14 @@ Let's get cracking! 🎉 ## 1. Add `/api` scope and pipeline -We want all our API requests -to be made under the `/api` route. -This is easier for us to manage changes to API -that don't create unnecessary regressions to our liveviews. +We want all `API` requests +to be made under the `/api` namespace. +This is easier for us to manage changes to `API` +that don't create unnecessary complexity in the `LiveView` code. Let's start by opening `lib/router.ex` and create a new `:api` pipeline -to be used under `scope "/api"`. +to be used under `scope "/api"`: ```elixir @@ -50,12 +50,12 @@ to be used under `scope "/api"`. ``` We are creating an `:api` pipeline -that will only accepts and returns `json` objects. +that will only accept and return `json` objects. `:fetch_session` is added as a plug because `:authOptional` requires us to do so. Every request that is routed to `/api` -will be piped through both `:api` and `:authOptional` pipelines. +will be piped through both the `:api` and `:authOptional` pipelines. You might have noticed two new controllers: `API.ItemController` and `API.TimerController`. @@ -71,7 +71,7 @@ Before creating our controller, let's define our requirements. We want the API t - edit an `item` We want each endpoint to respond appropriately if any data is invalid, -the response body and status should inform the user what went wrong. +the response body and status should inform the `person` what went wrong. We can leverage changesets to validate the `item` and `timer` and check if it's correctly formatted. @@ -86,13 +86,15 @@ Create two new files: - `test/app_web/api/timer_controller_test.exs` Before implementing, -we recommend giving a look at [`learn-api-design`](https://github.com/dwyl/learn-api-design), -we are going to be using some tips coming from there! +we recommend giving a look at +[`learn-api-design`](https://github.com/dwyl/learn-api-design), +we are going to be using some best practices described there! -We want the API requests to be handled gracefully +We want the `API` requests +to be handled gracefully when an error occurs. -The person using the API -[**should be shown meaningful errors**](https://github.com/dwyl/learn-api-design/blob/revamp/README.md#show-meaningful-errors). +The `person` using the `API` +[**should be shown _meaningful_ errors**](https://github.com/dwyl/learn-api-design/blob/main/README.md#show-meaningful-errors). Therefore, we need to test how our API behaves when invalid attributes are requested and/or an error occurs **and where**. @@ -113,7 +115,6 @@ defmodule AppWeb.API.ItemControllerTest do {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) conn = get(conn, Routes.item_path(conn, :show, item.id)) - assert conn.status == 200 assert json_response(conn, 200)["id"] == item.id assert json_response(conn, 200)["text"] == item.text end @@ -134,7 +135,6 @@ defmodule AppWeb.API.ItemControllerTest do test "a valid item", %{conn: conn} do conn = post(conn, Routes.item_path(conn, :create, @create_attrs)) - assert conn.status == 200 assert json_response(conn, 200)["text"] == Map.get(@create_attrs, "text") assert json_response(conn, 200)["status"] == @@ -147,7 +147,6 @@ defmodule AppWeb.API.ItemControllerTest do test "an invalid item", %{conn: conn} do conn = post(conn, Routes.item_path(conn, :create, @invalid_attrs)) - assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["text"]) > 0 end end @@ -157,7 +156,6 @@ defmodule AppWeb.API.ItemControllerTest do {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) conn = put(conn, Routes.item_path(conn, :update, item.id, @update_attrs)) - assert conn.status == 200 assert json_response(conn, 200)["text"] == Map.get(@update_attrs, :text) end @@ -165,14 +163,14 @@ defmodule AppWeb.API.ItemControllerTest do {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) conn = put(conn, Routes.item_path(conn, :update, item.id, @invalid_attrs)) - assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["text"]) > 0 end end end ``` -In `/item`, users will be able to +In `/item`, +a `person` will be able to **create**, **update** or **query a single item**. In each test we are testing successful scenarios (the [Happy Path](https://en.wikipedia.org/wiki/Happy_path)), diff --git a/test/app_web/controllers/api/item_controller_test.exs b/test/app_web/controllers/api/item_controller_test.exs index 5dcf0eb5..9d6df513 100644 --- a/test/app_web/controllers/api/item_controller_test.exs +++ b/test/app_web/controllers/api/item_controller_test.exs @@ -11,7 +11,6 @@ defmodule AppWeb.API.ItemControllerTest do {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) conn = get(conn, Routes.item_path(conn, :show, item.id)) - assert conn.status == 200 assert json_response(conn, 200)["id"] == item.id assert json_response(conn, 200)["text"] == item.text end @@ -32,7 +31,6 @@ defmodule AppWeb.API.ItemControllerTest do test "a valid item", %{conn: conn} do conn = post(conn, Routes.item_path(conn, :create, @create_attrs)) - assert conn.status == 200 assert json_response(conn, 200)["text"] == Map.get(@create_attrs, "text") assert json_response(conn, 200)["status"] == @@ -45,7 +43,6 @@ defmodule AppWeb.API.ItemControllerTest do test "an invalid item", %{conn: conn} do conn = post(conn, Routes.item_path(conn, :create, @invalid_attrs)) - assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["text"]) > 0 end end @@ -55,7 +52,6 @@ defmodule AppWeb.API.ItemControllerTest do {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) conn = put(conn, Routes.item_path(conn, :update, item.id, @update_attrs)) - assert conn.status == 200 assert json_response(conn, 200)["text"] == Map.get(@update_attrs, :text) end @@ -63,7 +59,6 @@ defmodule AppWeb.API.ItemControllerTest do {:ok, %{model: item, version: _version}} = Item.create_item(@create_attrs) conn = put(conn, Routes.item_path(conn, :update, item.id, @invalid_attrs)) - assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["text"]) > 0 end end From 4c2f9b7a159c3f61ae37be5eeb190e083c908fd6 Mon Sep 17 00:00:00 2001 From: nelsonic Date: Sun, 15 Jan 2023 07:54:54 +0000 Subject: [PATCH 19/25] simplify API folder structure and file names #256 --- api.md | 47 ++++++++++++------- .../api/item_controller.ex => api/item.ex} | 2 +- .../api/timer_controller.ex => api/timer.ex} | 2 +- lib/app_web/router.ex | 6 +-- .../item_test.exs} | 2 +- .../timer_test.exs} | 2 +- 6 files changed, 38 insertions(+), 23 deletions(-) rename lib/{app_web/controllers/api/item_controller.ex => api/item.ex} (98%) rename lib/{app_web/controllers/api/timer_controller.ex => api/timer.ex} (98%) rename test/{app_web/controllers/api/item_controller_test.exs => api/item_test.exs} (98%) rename test/{app_web/controllers/api/timer_controller_test.exs => api/timer_test.exs} (98%) diff --git a/api.md b/api.md index ac5dff45..a9ade606 100644 --- a/api.md +++ b/api.md @@ -17,7 +17,21 @@ can also be done through our `REST API` *and* `WebSocket API` (for all real-time updates). -Let's get cracking! 🎉 + +
+ +- [`REST`ful `API` Integration](#restful-api-integration) + - [1. Add `/api` scope and pipeline](#1-add-api-scope-and-pipeline) + - [2. `ItemController` and `TimerController`](#2-itemcontroller-and-timercontroller) + - [2.1 Adding tests](#21-adding-tests) + - [2.2 Implementing the controllers](#22-implementing-the-controllers) + - [3. `JSON` serializing](#3-json-serializing) + - [4. Listing `timers` and `items` and validating updates](#4-listing-timers-and-items-and-validating-updates) +- [Done! ✅](#done-) + + +
+ ## 1. Add `/api` scope and pipeline @@ -82,8 +96,8 @@ with a [`TDD mindset`](https://github.com/dwyl/learn-tdd) and create our tests first! Create two new files: -- `test/app_web/api/item_controller_test.exs` -- `test/app_web/api/timer_controller_test.exs` +- `test/api/item_test.exs` +- `test/api/timer_test.exs` Before implementing, we recommend giving a look at @@ -99,10 +113,11 @@ Therefore, we need to test how our API behaves when invalid attributes are requested and/or an error occurs **and where**. -Let's start with `item_controller_test.exs`. +Open `test/api/item_test.exs` +and add the following code: ```elixir -defmodule AppWeb.API.ItemControllerTest do +defmodule API.ItemTest do use AppWeb.ConnCase alias App.Item @@ -178,10 +193,10 @@ alongside situations where the person requests non-existent items or tries to create new ones with invalid attributes. -The same scenario occurs in `test/app_web/api/timer_controller_test.exs`. +Next, in the `test/api/timer_test.exs` file: ```elixir -defmodule AppWeb.API.TimerControllerTest do +defmodule API.TimerTest do use AppWeb.ConnCase alias App.Timer alias App.Item @@ -311,12 +326,12 @@ because these functions aren't defined. It's time to implement our sweet controllers! Let's start with `ItemController`. -Create a directory inside `lib/app_web/controllers/api` -and a file inside called `item_controller.ex`. -Paste the following code. +Create file with the path: +`lib/api/item.ex` +and add the following code: ```elixir -defmodule AppWeb.API.ItemController do +defmodule API.Item do use AppWeb, :controller alias App.Item import Ecto.Changeset @@ -469,12 +484,12 @@ To retrieve/update/create an `item`, we are simply calling the schema functions defined in `lib/app/timer.ex`. -Let's head over and create our `TimerController`! -Inside the same directory, create `timer_controller.ex` -and use this code. +Create a new file with the path: +`lib/api/timer.ex` +and add the following code: ```elixir -defmodule AppWeb.API.TimerController do +defmodule API.Timer do use AppWeb, :controller alias App.Timer import Ecto.Changeset @@ -767,7 +782,7 @@ it will error out! error_datetimes -# And you should be done! +# Done! ✅ This document is going to be expanded as development continues. diff --git a/lib/app_web/controllers/api/item_controller.ex b/lib/api/item.ex similarity index 98% rename from lib/app_web/controllers/api/item_controller.ex rename to lib/api/item.ex index c271d1d3..7e6bd09e 100644 --- a/lib/app_web/controllers/api/item_controller.ex +++ b/lib/api/item.ex @@ -1,4 +1,4 @@ -defmodule AppWeb.API.ItemController do +defmodule API.Item do use AppWeb, :controller alias App.Item import Ecto.Changeset diff --git a/lib/app_web/controllers/api/timer_controller.ex b/lib/api/timer.ex similarity index 98% rename from lib/app_web/controllers/api/timer_controller.ex rename to lib/api/timer.ex index c7c2163f..a7d0359b 100644 --- a/lib/app_web/controllers/api/timer_controller.ex +++ b/lib/api/timer.ex @@ -1,4 +1,4 @@ -defmodule AppWeb.API.TimerController do +defmodule API.Timer do use AppWeb, :controller alias App.Timer import Ecto.Changeset diff --git a/lib/app_web/router.ex b/lib/app_web/router.ex index e8bbfaac..e61db8b2 100644 --- a/lib/app_web/router.ex +++ b/lib/app_web/router.ex @@ -34,12 +34,12 @@ defmodule AppWeb.Router do resources "/tags", TagController, except: [:show] end - scope "/api", AppWeb do + scope "/api", API do pipe_through [:api, :authOptional] - resources "/items", API.ItemController, only: [:create, :update, :show] + resources "/items", Item, only: [:create, :update, :show] - resources "/items/:item_id/timers", API.TimerController, + resources "/items/:item_id/timers", Timer, only: [:create, :update, :show, :index] end end diff --git a/test/app_web/controllers/api/item_controller_test.exs b/test/api/item_test.exs similarity index 98% rename from test/app_web/controllers/api/item_controller_test.exs rename to test/api/item_test.exs index 9d6df513..cbf7c7ab 100644 --- a/test/app_web/controllers/api/item_controller_test.exs +++ b/test/api/item_test.exs @@ -1,4 +1,4 @@ -defmodule AppWeb.API.ItemControllerTest do +defmodule API.ItemTest do use AppWeb.ConnCase alias App.Item diff --git a/test/app_web/controllers/api/timer_controller_test.exs b/test/api/timer_test.exs similarity index 98% rename from test/app_web/controllers/api/timer_controller_test.exs rename to test/api/timer_test.exs index fcc53d56..3b273401 100644 --- a/test/app_web/controllers/api/timer_controller_test.exs +++ b/test/api/timer_test.exs @@ -1,4 +1,4 @@ -defmodule AppWeb.API.TimerControllerTest do +defmodule API.TimerTest do use AppWeb.ConnCase alias App.Timer alias App.Item From 91b4ae147704864646aaf991db600d6623db50f4 Mon Sep 17 00:00:00 2001 From: nelsonic Date: Sun, 15 Jan 2023 22:17:45 +0000 Subject: [PATCH 20/25] add test for timer_text(start, stop) UNDER 1000s to ensure branch coverage #256 --- test/app_web/live/app_live_test.exs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/app_web/live/app_live_test.exs b/test/app_web/live/app_live_test.exs index c561f674..4a4856e1 100644 --- a/test/app_web/live/app_live_test.exs +++ b/test/app_web/live/app_live_test.exs @@ -501,6 +501,15 @@ defmodule AppWeb.AppLiveTest do }) =~ "This timer interval overlaps with other timers." end + test "timer_text(start, stop) UNDER 1000s" do + timer = %{ + start: ~N[2022-07-17 09:01:42.000000], + stop: ~N[2022-07-17 09:02:24.000000] + } + + assert AppWeb.AppLive.timer_text(timer) == "00:00:42" + end + test "timer_text(start, stop)" do timer = %{ start: ~N[2022-07-17 09:01:42.000000], From 3ffe8386b3ad84bb1ba0ccf3d6e2c61d3f7146b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 16 Jan 2023 09:21:42 +0000 Subject: [PATCH 21/25] fix: Updating `api.md`. #256 --- api.md | 114 ++++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 77 insertions(+), 37 deletions(-) diff --git a/api.md b/api.md index a9ade606..c0965684 100644 --- a/api.md +++ b/api.md @@ -22,7 +22,7 @@ can also be done through our `REST API` - [`REST`ful `API` Integration](#restful-api-integration) - [1. Add `/api` scope and pipeline](#1-add-api-scope-and-pipeline) - - [2. `ItemController` and `TimerController`](#2-itemcontroller-and-timercontroller) + - [2. `API.Item` and `API.Timer`](#2-apiitem-and-apitimer) - [2.1 Adding tests](#21-adding-tests) - [2.2 Implementing the controllers](#22-implementing-the-controllers) - [3. `JSON` serializing](#3-json-serializing) @@ -75,7 +75,7 @@ You might have noticed two new controllers: `API.ItemController` and `API.TimerController`. We are going to need to create these to handle our requests! -## 2. `ItemController` and `TimerController` +## 2. `API.Item` and `API.Timer` Before creating our controller, let's define our requirements. We want the API to: @@ -118,7 +118,7 @@ and add the following code: ```elixir defmodule API.ItemTest do - use AppWeb.ConnCase +use AppWeb.ConnCase alias App.Item @create_attrs %{person_id: 42, status: 0, text: "some text"} @@ -180,6 +180,12 @@ defmodule API.ItemTest do assert length(json_response(conn, 400)["errors"]["text"]) > 0 end + + test "item that doesn't exist", %{conn: conn} do + conn = put(conn, Routes.item_path(conn, :update, -1, @invalid_attrs)) + + assert conn.status == 404 + end end end ``` @@ -232,7 +238,8 @@ defmodule API.TimerTest do test "not found timer", %{conn: conn} do # Create item - {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) conn = get(conn, Routes.timer_path(conn, :show, item.id, -1)) @@ -241,7 +248,8 @@ defmodule API.TimerTest do test "invalid id (not being an integer)", %{conn: conn} do # Create item - {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) conn = get(conn, Routes.timer_path(conn, :show, item.id, "invalid")) assert conn.status == 400 @@ -251,7 +259,8 @@ defmodule API.TimerTest do describe "create" do test "a valid timer", %{conn: conn} do # Create item - {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) # Create timer conn = @@ -265,7 +274,8 @@ defmodule API.TimerTest do test "an invalid timer", %{conn: conn} do # Create item - {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) conn = post(conn, Routes.timer_path(conn, :create, item.id, @invalid_attrs)) @@ -303,11 +313,18 @@ defmodule API.TimerTest do assert conn.status == 400 assert length(json_response(conn, 400)["errors"]["start"]) > 0 end + + test "timer that doesn't exist", %{conn: conn} do + conn = put(conn, Routes.timer_path(conn, :update, -1, -1, @invalid_attrs)) + + assert conn.status == 404 + end end defp item_and_timer_fixture() do # Create item - {:ok, %{model: item, version: _version}} = Item.create_item(@create_item_attrs) + {:ok, %{model: item, version: _version}} = + Item.create_item(@create_item_attrs) # Create timer started = NaiveDateTime.utc_now() @@ -324,7 +341,7 @@ because these functions aren't defined. ### 2.2 Implementing the controllers It's time to implement our sweet controllers! -Let's start with `ItemController`. +Let's start with `API.Item`. Create file with the path: `lib/api/item.ex` @@ -394,21 +411,32 @@ defmodule API.Item do id = Map.get(params, "id") new_text = Map.get(params, "text") - item = Item.get_item!(id) + # Get item with the ID + case Item.get_item(id) do + nil -> + errors = %{ + code: 404, + message: "No item found with the given \'id\'." + } - case Item.update_item(item, %{text: new_text}) do - # Successfully updates item - {:ok, %{model: item, version: _version}} -> - json(conn, item) + json(conn |> put_status(404), errors) - # Error creating item - {:error, %Ecto.Changeset{} = changeset} -> - errors = make_changeset_errors_readable(changeset) + # If item is found, try to update it + item -> + case Item.update_item(item, %{text: new_text}) do + # Successfully updates item + {:ok, %{model: item, version: _version}} -> + json(conn, item) - json( - conn |> put_status(400), - errors - ) + # Error creating item + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end end end @@ -511,6 +539,7 @@ defmodule API.Timer do code: 404, message: "No timer found with the given \'id\'." } + json(conn |> put_status(404), errors) timer -> @@ -537,7 +566,6 @@ defmodule API.Timer do } case Timer.start(attrs) do - # Successfully creates item {:ok, timer} -> id_timer = Map.take(timer, [:id]) @@ -555,35 +583,47 @@ defmodule API.Timer do end def update(conn, params) do + + id = Map.get(params, "id") + # Attributes to update timer attrs_to_update = %{ - id: Map.get(params, "id"), start: Map.get(params, "start"), stop: Map.get(params, "stop") } - case Timer.update_timer(attrs_to_update) do + case Timer.get_timer(id) do + nil -> + errors = %{ + code: 404, + message: "No timer found with the given \'id\'." + } - # Successfully updates timer - {:ok, timer} -> - json(conn, timer) + json(conn |> put_status(404), errors) - # Error creating timer - {:error, %Ecto.Changeset{} = changeset} -> - errors = make_changeset_errors_readable(changeset) + # If timer is found, try to update it + timer -> + case Timer.update_timer(timer, attrs_to_update) do + # Successfully updates timer + {:ok, timer} -> + json(conn, timer) - json( - conn |> put_status(400), - errors - ) - end - end + # Error creating timer + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_changeset_errors_readable(changeset) + json( + conn |> put_status(400), + errors + ) + end + end + end defp make_changeset_errors_readable(changeset) do errors = %{ code: 400, - message: "Malformed request", + message: "Malformed request" } changeset_errors = traverse_errors(changeset, fn {msg, _opts} -> msg end) From f7e8bb883b00e29915379c21532faf21bd87627f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 16 Jan 2023 09:23:52 +0000 Subject: [PATCH 22/25] fix: Mix format. #256 --- lib/api/timer.ex | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/api/timer.ex b/lib/api/timer.ex index 0cbef66a..8e4e33da 100644 --- a/lib/api/timer.ex +++ b/lib/api/timer.ex @@ -64,7 +64,6 @@ defmodule API.Timer do end def update(conn, params) do - id = Map.get(params, "id") # Attributes to update timer @@ -98,7 +97,7 @@ defmodule API.Timer do errors ) end - end + end end defp make_changeset_errors_readable(changeset) do From dceaaa0ea132103470c2e9fb05405a6845a04ba5 Mon Sep 17 00:00:00 2001 From: nelsonic Date: Mon, 16 Jan 2023 13:16:09 +0000 Subject: [PATCH 23/25] improve API error message clarity #256 --- api.md => API.md | 8 ++++++-- BUILDIT.md | 9 +++++---- lib/api/item.ex | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) rename api.md => API.md (99%) diff --git a/api.md b/API.md similarity index 99% rename from api.md rename to API.md index c0965684..343e4ca7 100644 --- a/api.md +++ b/API.md @@ -1,6 +1,6 @@
-# `REST`ful `API` Integration +# `REST`ful `API` Integration
@@ -829,4 +829,8 @@ as development continues. So if you're reading this, it's because that's all we currently have! If you found it interesting, -please let us know by starring the repo on GitHub! ⭐ \ No newline at end of file +please let us know by starring the repo on GitHub! ⭐ + +
+ +[![HitCount](https://hits.dwyl.com/dwyl/app-mvp-api.svg)](https://hits.dwyl.com/dwyl/app-mvp) \ No newline at end of file diff --git a/BUILDIT.md b/BUILDIT.md index 5f31d873..73007ae3 100644 --- a/BUILDIT.md +++ b/BUILDIT.md @@ -46,9 +46,10 @@ We suggest keeping two terminal tabs/windows running one for the server `mix phx.server` and the other for the tests. That way you can also see the UI as you progress. -We have created a *separate* -document detailing the implementation of the API. -You can find it in [`api.md`](./api.md). +We created a *separate* +document detailing the implementation of the `API`. +Please see: +[`api.md`](./api.md). With that in place, let's get building! @@ -105,7 +106,7 @@ With that in place, let's get building! - [13.1 Setting up](#131-setting-up) - [13.2 Changing database transactions on `item` insert and update](#132-changing-database-transactions-on-item-insert-and-update) - [13.3 Fixing tests](#133-fixing-tests) - - [13.4 Checking the changes using `DBEaver`](#134-checking-the-changes-using-DBEaver) + - [13.4 Checking the changes using `DBEaver`](#134-checking-the-changes-using-dbeaver) - [14. Adding a dashboard to track metrics](#14-adding-a-dashboard-to-track-metrics) - [14.1 Adding new `LiveView` page in `/stats`](#141-adding-new-liveview-page-in-stats) - [14.2 Fetching counter of timers and items for each person](#142-fetching-counter-of-timers-and-items-for-each-person) diff --git a/lib/api/item.ex b/lib/api/item.ex index b6a1a238..fac4cbde 100644 --- a/lib/api/item.ex +++ b/lib/api/item.ex @@ -24,7 +24,7 @@ defmodule API.Item do :error -> errors = %{ code: 400, - message: "The \'id\' is not an integer." + message: "Item \'id\' should be an integer." } json(conn |> put_status(400), errors) From e6d4ef084e5e68320163b4d594a85353557affc5 Mon Sep 17 00:00:00 2001 From: nelsonic Date: Mon, 16 Jan 2023 14:14:25 +0000 Subject: [PATCH 24/25] fix merge conflicts in BUILDIT.md #256 (AGAIN!) --- API.md | 111 +++++++++++++++++++++++++++++++++++++++++------ BUILDIT.md | 2 +- lib/api/timer.ex | 2 +- 3 files changed, 99 insertions(+), 16 deletions(-) diff --git a/API.md b/API.md index 343e4ca7..95d0ac8b 100644 --- a/API.md +++ b/API.md @@ -1,6 +1,6 @@
-# `REST`ful `API` Integration +# `API` Integration
@@ -20,20 +20,27 @@ can also be done through our `REST API`
-- [`REST`ful `API` Integration](#restful-api-integration) - - [1. Add `/api` scope and pipeline](#1-add-api-scope-and-pipeline) - - [2. `API.Item` and `API.Timer`](#2-apiitem-and-apitimer) - - [2.1 Adding tests](#21-adding-tests) - - [2.2 Implementing the controllers](#22-implementing-the-controllers) - - [3. `JSON` serializing](#3-json-serializing) - - [4. Listing `timers` and `items` and validating updates](#4-listing-timers-and-items-and-validating-updates) +- [`API` Integration](#api-integration) +- [1. Add `/api` scope and pipeline in `router.ex`](#1-add-api-scope-and-pipeline-in-routerex) +- [2. `API.Item` and `API.Timer`](#2-apiitem-and-apitimer) + - [2.1 Adding tests](#21-adding-tests) + - [2.2 Implementing the controllers](#22-implementing-the-controllers) +- [3. `JSON` serializing](#3-json-serializing) +- [4. Listing `timers` and `items` and validating updates](#4-listing-timers-and-items-and-validating-updates) +- [5. Basic `API` Testing Using `cUrl`](#5-basic-api-testing-using-curl) + - [5.1 _Create_ an `item` via `API` Request](#51-create-an-item-via-api-request) + - [5.2 _Read_ the `item` via `API`](#52-read-the-item-via-api) + - [5.3 Create a `Timer` for your `item`](#53-create-a-timer-for-your-item) + - [5.4 _Stop_ the `Timer`](#54-stop-the-timer) +- [TODO: Update once this is working](#todo-update-once-this-is-working) +- [6. _Advanced/Automated_ `API` Testing Using `Hoppscotch`](#6-advancedautomated-api-testing-using-hoppscotch) - [Done! ✅](#done-)
-## 1. Add `/api` scope and pipeline +# 1. Add `/api` scope and pipeline in `router.ex` We want all `API` requests to be made under the `/api` namespace. @@ -75,7 +82,7 @@ You might have noticed two new controllers: `API.ItemController` and `API.TimerController`. We are going to need to create these to handle our requests! -## 2. `API.Item` and `API.Timer` +# 2. `API.Item` and `API.Timer` Before creating our controller, let's define our requirements. We want the API to: @@ -89,7 +96,7 @@ the response body and status should inform the `person` what went wrong. We can leverage changesets to validate the `item` and `timer` and check if it's correctly formatted. -### 2.1 Adding tests +## 2.1 Adding tests Let's approach this with a [`TDD mindset`](https://github.com/dwyl/learn-tdd) @@ -338,7 +345,7 @@ end If you run `mix test`, they will fail, because these functions aren't defined. -### 2.2 Implementing the controllers +## 2.2 Implementing the controllers It's time to implement our sweet controllers! Let's start with `API.Item`. @@ -641,7 +648,7 @@ that *is not yet implemented* in `lib/app/timer.ex` - **`list_timers/1`**. -## 3. `JSON` serializing +# 3. `JSON` serializing Let's look at `index` in `lib/app/timer.ex`. You may notice that we are returning a `JSON` @@ -705,7 +712,7 @@ and serialize them as `JSON` objects so they can be returned to the person using the API! ✨ -## 4. Listing `timers` and `items` and validating updates +# 4. Listing `timers` and `items` and validating updates Let's implement `list_timers/1` in `lib/app/timer.ex`. @@ -822,6 +829,82 @@ it will error out! error_datetimes +# 5. Basic `API` Testing Using `cUrl` + +At this point we have a working `API` for `items` and `timers`. +We can demonstrate it using `curl` commands in the `Terminal`. + +1. Run the `Phoenix` App with the command: `mix s` +2. In a _separate_ `Terminal` window, run the following commands: + +## 5.1 _Create_ an `item` via `API` Request + +```sh +curl -X POST http://localhost:4000/api/items -H 'Content-Type: application/json' -d '{"text":"My Awesome item text"}' +``` +You should expect to see the following result: + +```sh +{"id":1} +``` + +## 5.2 _Read_ the `item` via `API` + +Now if you request this `item` using the `id`: + +```sh +curl http://localhost:4000/api/items/1 +``` + +You should see: + +```sh +{"id":1,"person_id":0,"status":2,"text":"My Awesome item text"} +``` + +This tells us that `items` are being created. ✅ + +## 5.3 Create a `Timer` for your `item` + +The route pattern is: `/api/items/:item_id/timers`. +Therefore our `cURL` request is: + +```sh +curl -X POST http://localhost:4000/api/items/1/timers -H 'Content-Type: application/json' -d '{"start":"2022-10-28T00:00:00"}' +``` + +You should see a response similar to the following: + +```sh +{"id":1} +``` + +This is the `timer.id` and informs us that the timer is running. + +> **Note**: the `API` should allow me to create a `timer` +> _without_ having to specify the `start` time. +> See: https://github.com/dwyl/mvp/issues/256#issuecomment-1384091996 + + +## 5.4 _Stop_ the `Timer` + +The path to `stop` a timer is: `/api/items/:item_id/timers/:id` +It _should_ be `/api/timers/:id` ... +https://github.com/dwyl/mvp/issues/256#issuecomment-1384104504 + + +# TODO: Update once this is working + +Revisit this once the `API` route has been updated. +`timers` should not be bound to `item` once they are created. + + +# 6. _Advanced/Automated_ `API` Testing Using `Hoppscotch` + +Coming soon! https://github.com/dwyl/mvp/issues/268 + + + # Done! ✅ This document is going to be expanded diff --git a/BUILDIT.md b/BUILDIT.md index 73007ae3..9fb27e5b 100644 --- a/BUILDIT.md +++ b/BUILDIT.md @@ -49,7 +49,7 @@ That way you can also see the UI as you progress. We created a *separate* document detailing the implementation of the `API`. Please see: -[`api.md`](./api.md). +[`API.md`](./API.md). With that in place, let's get building! diff --git a/lib/api/timer.ex b/lib/api/timer.ex index 8e4e33da..681e1137 100644 --- a/lib/api/timer.ex +++ b/lib/api/timer.ex @@ -31,7 +31,7 @@ defmodule API.Timer do :error -> errors = %{ code: 400, - message: "The \'id\' is not an integer." + message: "Timer \'id\' should be an integer." } json(conn |> put_status(400), errors) From ca34ba014c1259f98f916c4dcd2591af65f66080 Mon Sep 17 00:00:00 2001 From: nelsonic Date: Mon, 16 Jan 2023 14:13:03 +0000 Subject: [PATCH 25/25] fix merge conflicts in BUILDIT.md #256 --- lib/app/datetime_parser.ex | 198 +++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 lib/app/datetime_parser.ex diff --git a/lib/app/datetime_parser.ex b/lib/app/datetime_parser.ex new file mode 100644 index 00000000..c73b9a95 --- /dev/null +++ b/lib/app/datetime_parser.ex @@ -0,0 +1,198 @@ +# Credit: https://dev.to/onpointvn/build-your-own-date-time-parser-in-elixir-50be +# See: https://gist.github.com/bluzky/62a20cdb57b17f47c67261c10aa3da8b +defmodule App.DateTimeParser do + @mapping %{ + "H" => "(?\\d{2})", + "I" => "(?\\d{2})", + "M" => "(?\\d{2})", + "S" => "(?\\d{2})", + "d" => "(?\\d{2})", + "m" => "(?\\d{2})", + "y" => "(?\\d{2})", + "Y" => "(?-?\\d{4})", + "z" => "(?[+-]?\\d{4})", + "Z" => "(?[a-zA-Z_\/]+)", + "p" => "(?

PM|AM)", + "P" => "(?

pm|am)", + "%" => "%" + } + + @doc """ + Parse string to datetime struct + **Example**: + parse("2021-20-10", "%Y-%M-%d") + Support format + | format | description| value example | + | -- | -- | -- | + | H | 24 hour | 00 - 23 | + | I | 12 hour | 00 - 12 | + | M | minute| 00 - 59 | + | S | second | 00 - 59 | + | d | day | 01 - 31 | + | m | month | 01 -12 | + | y | 2 digits year | 00 - 99 | + | Y | 4 digits year | | + | z | timezone offset | +0100, -0330 | + | Z | timezone name | UTC+7, Asia/Ho_Chi_Minh | + | p | PM or AM | | + | P | pm or am | | + """ + def parse!(dt_string, format \\ "%Y-%m-%dT%H:%M:%SZ") do + case parse(dt_string, format) do + {:ok, dt} -> + dt + + {:error, message} -> + raise "Parse string #{dt_string} with error: #{message}" + end + end + + @doc """ + Parses the string according to the format. + Pipes through regex compilation, casts each part of the string + to a named regex capture and tries to convert to datetime. + """ + def parse(dt_string, format \\ "%Y-%m-%dT%H:%M:%SZ") do + format + |> build_regex + |> Regex.named_captures(dt_string) + |> cast_data + |> to_datetime + end + + @doc """ + Builds the regex expression to later be captured (have named key-value captures).any() + This uses the @mapping structure to name specific parts of the entered string to convert to datetime. + """ + def build_regex(format) do + keys = Map.keys(@mapping) |> Enum.join("") + + Regex.compile!("([^%]*)%([#{keys}])([^%]*)") + |> Regex.scan(format) + |> Enum.map(fn [_, s1, key, s2] -> + [s1, Map.get(@mapping, key), s2] + end) + |> to_string() + |> Regex.compile!() + end + + @default_value %{ + day: 1, + month: 1, + year: 0, + hour: 0, + minute: 0, + second: 0, + utc_offset: 0, + tz_name: "UTC", + shift: "AM" + } + def cast_data(nil), do: {:error, "invalid datetime"} + + @doc """ + Casts each capture of the regex to appropriated format (compatible with DateTime struct) + """ + def cast_data(captures) do + captures + |> Enum.reduce_while([], fn {part, value}, acc -> + {:ok, data} = cast(part, value) + {:cont, [data | acc]} + end) + |> Enum.into(@default_value) + end + + @value_rages %{ + "hour" => [0, 23], + "hour12" => [0, 12], + "minute" => [0, 59], + "second" => [0, 59], + "day" => [0, 31], + "month" => [1, 12], + "year2" => [0, 99] + } + + defp cast("P", value) do + cast("p", String.upcase(value)) + end + + defp cast("p", value) do + {:ok, {:shift, value}} + end + + defp cast("tz", value) do + {hour, minute} = String.split_at(value, 3) + + with {:ok, {_, hour}} <- cast("offset_h", hour), + {:ok, {_, minute}} <- cast("offset_m", minute) do + sign = div(hour, abs(hour)) + {:ok, {:utc_offset, sign * (abs(hour) * 3600 + minute * 60)}} + end + end + + defp cast("tz_name", value) do + {:ok, {:tz_name, value}} + end + + defp cast(part, value) do + value = String.to_integer(value) + + valid = + case Map.get(@value_rages, part) do + [min, max] -> + value >= min and value <= max + + _ -> + true + end + + if valid do + {:ok, {String.to_atom(part), value}} + end + end + + defp to_datetime({:error, _} = error), do: error + + defp to_datetime(%{year2: value} = data) do + current_year = DateTime.utc_now() |> Map.get(:year) + year = div(current_year, 100) * 100 + value + + data + |> Map.put(:year, year) + |> Map.delete(:year2) + |> to_datetime() + end + + defp to_datetime(%{hour12: hour} = data) do + # 12AM is not valid + + if hour == 12 and data.shift == "AM" do + {:error, "12AM is invalid value"} + else + hour = + cond do + hour == 12 and data.shift == "PM" -> hour + data.shift == "AM" -> hour + data.shift == "PM" -> hour + 12 + end + + data + |> Map.put(:hour, hour) + |> Map.delete(:hour12) + |> to_datetime() + end + end + + defp to_datetime(data) do + with {:ok, date} <- Date.new(data.year, data.month, data.day), + {:ok, time} <- Time.new(data.hour, data.minute, data.second), + {:ok, datetime} <- DateTime.new(date, time) do + datetime = DateTime.add(datetime, -data.utc_offset, :second) + + if data.tz_name != "UTC" do + {:error, "Only UTC timezone is available"} + else + {:ok, datetime} + end + end + end +end