diff --git a/README.md b/README.md index 890b9fdf..dbf816ff 100644 --- a/README.md +++ b/README.md @@ -2567,12 +2567,12 @@ e.g: -### 12 (Bonus!) Adding authentication +### 12. Authentication (Optional) Currently, the application allows *anyone* -to access it and manage todo items. -However, wouldn't it be awesome if -we added *authentication* so each user +to access it and manage todo `items`. +Wouldn't it be great if +we added *authentication* so each `person` could check their own list? We created a dedicated authentication guide: @@ -2600,6 +2600,23 @@ e.g: https://phxtodo.fly.dev
xs +### 13. REST API (Optional) + +Our `Phoenix` server currently +only returns **`HTML` pages** +that are **_server-side_ rendered**. +This is already *awesome* +but we can make use of `Phoenix` +to extend its capabilities. + +What if our server also responded +with `JSON`? +You're in luck! +We've created small guide +for creating a `REST API`: +[**`api.md`**](./api.md) + +
### Done! diff --git a/api.md b/api.md new file mode 100644 index 00000000..f2e71c4e --- /dev/null +++ b/api.md @@ -0,0 +1,470 @@ +
+ +# Create a Basic `REST` API to Return `JSON` Data + +
+ +This guide demonstrates +how to *extend* a `Phoenix` App +so it also acts as an **API** +returning `JSON` data. + +## Add `/api` scope and pipeline + +Open the +`lib/router.ex` file. +There is already a +`pipeline :browser` +that is used inside the `scope "/"`. + +```elixir + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, {AppWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + scope "/", AppWeb do + pipe_through :browser + + get "/", ItemController, :index + get "/items/toggle/:id", ItemController, :toggle + get "/items/clear", ItemController, :clear_completed + get "/items/:filter", ItemController, :index + resources "/items", ItemController + end +``` + +What this means is that +every time a request is made +to any of the aforementioned endpoints, +the pipeline `:browser` acts as a *middleware*, +going through each `plug` defined. +Check the first plug. +It says `plug :accepts, ["html"]`. +It means it only accepts requests +to return an HTML page. + +We want to do something similar to all of this, +but to return a `JSON` object. +For this, +add the following piece of code. + +```elixir + pipeline :api do + plug :accepts, ["json"] + end + + scope "/api", AppWeb do + pipe_through :api + + put "/items/:id/status", ApiController, :update_status + resources "items", ApiController, only: [:create, :update, :index] + end +``` + +This creates a pipeline `:api` +that only accepts requests for `JSON` data. + +We have also added a scope. +All routes starting with `/api` +will be piped through with the +`:api` pipeline +and handled by the `ApiController`. +Speaking of which, +let's create it! + +## Create the API Controller + +Before creating our controller, +let's define our requirements. +We want the API to: + +- list `item`s +- create an `item` +- edit an `item` +- update an `item`'s status + +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 check if it's correctly formatted. + +Since we now know what to do, +let's create our tests. + +### Adding Tests + +Before writing tests, +we need to change the +`test/support/fixtures/todo_fixtures.ex` file. +This file contains a function +that is used to create an `Item` for testing. + +Currently, the returned default `Item` is invalid. +This is because it returns a `status: 42`, +which according to our requirements, +doesn't make sense. +The `status` field can only be `0`, `1` or `2`. + +Change the `item_fixture/1` function +so it looks like this: + +```elixir + def item_fixture(attrs \\ %{}) do + {:ok, item} = + attrs + |> Enum.into(%{ + person_id: 42, + status: 0, + text: "some text" + }) + |> App.Todo.create_item() + item + end +``` + +Now, let's create +our controller tests. +Create a file with the path: +`test/app_web/controllers/api_controller_test.exs` +and add the following code: + +```elixir +defmodule AppWeb.ApiControllerTest do + use AppWeb.ConnCase + alias App.Todo + + @create_attrs %{person_id: 42, status: 0, text: "some text"} + @update_attrs %{person_id: 43, status: 0, text: "some updated text"} + @update_status_attrs %{status: 1} + @invalid_attrs %{person_id: nil, status: nil, text: nil} + @invalid_status_attrs %{status: 6} + + describe "list" do + test "all items", %{conn: conn} do + {:ok, item} = Todo.create_item(@create_attrs) + conn = get(conn, ~p"/api/items") + + assert conn.status == 200 + assert length(Jason.decode!(response(conn, 200))) == 1 + end + end + + describe "create" do + test "a valid item", %{conn: conn} do + conn = post(conn, ~p"/api/items", @create_attrs) + + assert conn.status == 200 + assert Map.get(Jason.decode!(response(conn, 200)), :text) == Map.get(@create_attrs, "text") + + assert Map.get(Jason.decode!(response(conn, 200)), :status) == + Map.get(@create_attrs, "status") + + assert Map.get(Jason.decode!(response(conn, 200)), :person_id) == + Map.get(@create_attrs, "person_id") + end + + test "an invalid item", %{conn: conn} do + conn = post(conn, ~p"/api/items", @invalid_attrs) + + assert conn.status == 400 + + error_text = response(conn, 400) |> Jason.decode!() |> Map.get("text") + assert error_text == ["can't be blank"] + end + end + + describe "update" do + test "item with valid attributes", %{conn: conn} do + {:ok, item} = Todo.create_item(@create_attrs) + + conn = put(conn, ~p"/api/items/#{item.id}", @update_attrs) + assert conn.status == 200 + assert Map.get(Jason.decode!(response(conn, 200)), :text) == Map.get(@update_attrs, "text") + end + + test "item with invalid attributes", %{conn: conn} do + {:ok, item} = Todo.create_item(@create_attrs) + + conn = put(conn, ~p"/api/items/#{item.id}", @invalid_attrs) + + assert conn.status == 400 + error_text = response(conn, 400) |> Jason.decode!() |> Map.get("text") + assert error_text == ["can't be blank"] + end + end + + describe "update item status" do + test "with valid attributes", %{conn: conn} do + {:ok, item} = Todo.create_item(@create_attrs) + + conn = put(conn, ~p"/api/items/#{item.id}/status", @update_status_attrs) + assert conn.status == 200 + + assert Map.get(Jason.decode!(response(conn, 200)), :status) == + Map.get(@update_status_attrs, "status") + end + + test "with invalid attributes", %{conn: conn} do + {:ok, item} = Todo.create_item(@create_attrs) + + conn = put(conn, ~p"/api/items/#{item.id}/status", @invalid_status_attrs) + + assert conn.status == 400 + error_text = response(conn, 400) |> Jason.decode!() |> Map.get("status") + assert error_text == ["must be less than or equal to 2"] + end + end +end +``` + +Let's break down what we just wrote. +We've created constants for each scenario we want: +testing valid or invalid attributes +for each endpoint: +- `create`, referring to creating an `Item`. +- `update`, referring to updating an `Item`'s text. +- `update_status`, referring to updating an `Item`'s status. + +The `ApiController` will have these three functions. +Let's look at the `describe "create"` suite. +The first test checks if a **valid `item`** is created. +If it is created, +the item should be returned to the user in `JSON` format. +The second test checks if an **_invalid_ `item`** +was attempted to be created. +It should return a response with +[HTTP Status Code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status) +`400` (meaning the client made a bad request) +and an error text accompanying the body. +These tests are replicated +on the other two functions. + +If you run the tests `mix test`, +they will fail, +because these functions aren't defined. + +### Create API Controller + +Let's create our API controller. +Inside `lib/app_web/controllers`, +create a file called +`api_controller.ex`. + +Use the following code. + +```elixir +defmodule AppWeb.ApiController do + use AppWeb, :controller + alias App.Todo + import Ecto.Changeset + + def index(conn, params) do + items = Todo.list_items() + json(conn, items) + end + + def create(conn, params) do + case Todo.create_item(params) do + # Successfully creates item + {:ok, item} -> + json(conn, item) + + # Error creating item + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + + def update(conn, params) do + id = Map.get(params, "id") + text = Map.get(params, "text", "") + + item = Todo.get_item!(id) + + case Todo.update_item(item, %{text: text}) do + # Successfully updates item + {:ok, item} -> + json(conn, item) + + # Error creating item + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + + def update_status(conn, params) do + id = Map.get(params, "id") + status = Map.get(params, "status", "") + + item = Todo.get_item!(id) + + case Todo.update_item(item, %{status: status}) do + # Successfully updates item + {:ok, item} -> + json(conn, item) + + # Error creating item + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + + defp make_errors_readable(changeset) do + 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) + end +end +``` + +We have created three functions, +each coresponding to the endpoints +`[:create, :update, :index]` +we defined earlier in `router.ex`. +They all follow the same flow: +try to do an action; +if it fails, return an error to the user. + +Let's review the `update/2` function. +The `params` parameter gives us information +about the body of the request +and the URL parameter. +Since the user accesses `/api/items/:id`, +an `id` field is present in the `params` map. +Similarly, the user sends a body +with the new text. + +```json +{ + "text": "new text" +} +``` + +which can be accessed as `params.text`. + +Next, we use the passed `id` +to check and fetch the item from the database. +After this, we pass the fetched item +with the new `text` to update +(calling `Todo.update_item`). + +Depending on the success of the operation, +different results are returned to the user. +If it succeeds, +the updated item is returned to the user, +alongside an HTTP status code of `200`. +On the other hand, if there's an error, +an error is returned to the user, +alongside an HTTP status code of `400`. + +The error is fetched from the changeset +(that validates the passed attributes) +and made readable by the +`make_errors_readable/1` function. +You don't need to know about the details, +it just fetches the errors from the `changeset` struct +and converts it to a map that can be +serializable to JSON format. + +If we use +[Postman](https://www.postman.com/) +to make an API call, +you will see the API in action. + +> Postman is a tool +that makes it easy to test API requests. + +update_postman + +## Adding validations to `Item` changeset + +There's one last thing we need to change. +We want to add validations to the `Item` changeset +so we can make sure the `Item` is valid +before adding it to the database. +We also want the user to receive useful information +if he unwillingly passed invalid attributes. + +Open `lib/app/todo/item.ex` +and change `changeset/2` +to the following. + +```elixir + def changeset(item, attrs) do + item + |> cast(attrs, [:text, :person_id, :status]) + |> validate_required([:text]) + |> validate_number(:status, greater_than_or_equal_to: 0, less_than_or_equal_to: 2) + |> validate_length(:text, min: 0) + end +``` + +We are now verifying +if the `status` number is between `0` and `1` +and checking the length of the `text` to be updated. + +This is great. +*However*, a `changeset` struct +is not serializable to `JSON`. +We need to tell the `JSON` serializer +which fields we want to retain in the `Item` schema. + +For this, +we need to add +the following annotation +on top of the schema definition +inside `lib/app/todo/item.ex`. + +```elixir + @derive {Jason.Encoder, only: [:id, :person_id, :status, :text]} + schema "items" do + field :person_id, :integer, default: 0 + field :status, :integer, default: 0 + field :text, :string + timestamps() + end +``` + +We are telling the `Jason` library +that when encoding or decoding `Item` structs, +we are only interested in the +`id`, `person_id`, `status` and `text` fields +(instead of other fields like +`updated_at`, `inserted_at` or `__meta__`). + +## Congratulations! + +Congratulations, +you just added REST API capabilities +to your `Phoenix` server! +It now serves a Todo application +**and** also serves an API for people to use it. + +## Try It! + +> update this section once the API is deployed to Fly.io ... diff --git a/config/dev.exs b/config/dev.exs index fabc4d9f..6c5b9b27 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -75,6 +75,3 @@ config :phoenix, :stacktrace_depth, 20 # Initialize plugs at runtime for faster development compilation config :phoenix, :plug_init_mode, :runtime - -# Disable swoosh api client as it is only required for production adapters. -config :swoosh, :api_client, false diff --git a/config/prod.exs b/config/prod.exs index 4eb9e8ee..608ef137 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -11,9 +11,6 @@ import Config # before starting your production server. config :app, AppWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" -# Configures Swoosh API Client -config :swoosh, :api_client, App.Finch - # Do not print debug messages in production config :logger, level: :info diff --git a/config/test.exs b/config/test.exs index 2da5d30e..093d2d02 100644 --- a/config/test.exs +++ b/config/test.exs @@ -20,12 +20,6 @@ config :app, AppWeb.Endpoint, secret_key_base: "2SvfXDONqim1uZkFDFIDe9r0Aw9vvI4uJ5M8JRP5guuW6vs/RtSsu0rw58cWpacP", server: false -# In test we don't send emails. -config :app, App.Mailer, adapter: Swoosh.Adapters.Test - -# Disable swoosh api client as it is only required for production adapters. -config :swoosh, :api_client, false - # Print only warnings and errors during test config :logger, level: :warning diff --git a/lib/app/todo/item.ex b/lib/app/todo/item.ex index af917734..b18d3a96 100644 --- a/lib/app/todo/item.ex +++ b/lib/app/todo/item.ex @@ -2,6 +2,7 @@ defmodule App.Todo.Item do use Ecto.Schema import Ecto.Changeset + @derive {Jason.Encoder, only: [:id, :person_id, :status, :text]} schema "items" do field :person_id, :integer, default: 0 field :status, :integer, default: 0 @@ -15,5 +16,7 @@ defmodule App.Todo.Item do item |> cast(attrs, [:text, :person_id, :status]) |> validate_required([:text]) + |> validate_number(:status, greater_than_or_equal_to: 0, less_than_or_equal_to: 2) + |> validate_length(:text, min: 0) end end diff --git a/lib/app_web/controllers/api_controller.ex b/lib/app_web/controllers/api_controller.ex new file mode 100644 index 00000000..bc6908c5 --- /dev/null +++ b/lib/app_web/controllers/api_controller.ex @@ -0,0 +1,79 @@ +defmodule AppWeb.ApiController do + use AppWeb, :controller + alias App.Todo + import Ecto.Changeset + + def index(conn, _params) do + items = Todo.list_items() + json(conn, items) + end + + def create(conn, params) do + case Todo.create_item(params) do + # Successfully creates item + {:ok, item} -> + json(conn, item) + + # Error creating item + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + + def update(conn, params) do + id = Map.get(params, "id") + text = Map.get(params, "text", "") + + item = Todo.get_item!(id) + + case Todo.update_item(item, %{text: text}) do + # Successfully updates item + {:ok, item} -> + json(conn, item) + + # Error creating item + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + + def update_status(conn, params) do + id = Map.get(params, "id") + status = Map.get(params, "status", "") + + item = Todo.get_item!(id) + + case Todo.update_item(item, %{status: status}) do + # Successfully updates item + {:ok, item} -> + json(conn, item) + + # Error creating item + {:error, %Ecto.Changeset{} = changeset} -> + errors = make_errors_readable(changeset) + + json( + conn |> put_status(400), + errors + ) + end + end + + defp make_errors_readable(changeset) do + 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) + end +end diff --git a/lib/app_web/router.ex b/lib/app_web/router.ex index eaa3c0ba..c2d7bbbf 100644 --- a/lib/app_web/router.ex +++ b/lib/app_web/router.ex @@ -10,10 +10,6 @@ defmodule AppWeb.Router do plug :put_secure_browser_headers end - # pipeline :api do - # plug :accepts, ["json"] - # end - pipeline :authOptional, do: plug(AuthPlugOptional) scope "/", AppWeb do @@ -28,8 +24,14 @@ defmodule AppWeb.Router do resources "/items", ItemController, except: [:show] end - # Other scopes may use custom stacks. - # scope "/api", AppWeb do - # pipe_through :api - # end + pipeline :api do + plug :accepts, ["json"] + end + + scope "/api", AppWeb do + pipe_through :api + + put "/items/:id/status", ApiController, :update_status + resources "/items", ApiController, only: [:create, :update, :index] + end end diff --git a/mix.exs b/mix.exs index a1e021e2..c9333a11 100644 --- a/mix.exs +++ b/mix.exs @@ -52,13 +52,13 @@ defmodule App.MixProject do {:phoenix_live_dashboard, "~> 0.7.2"}, {:esbuild, "~> 0.5", runtime: Mix.env() == :dev}, {:tailwind, "~> 0.1.8", runtime: Mix.env() == :dev}, - {:swoosh, "~> 1.3"}, {:finch, "~> 0.13"}, {:telemetry_metrics, "~> 0.6"}, {:telemetry_poller, "~> 1.0"}, {:gettext, "~> 0.20"}, {:jason, "~> 1.2"}, {:plug_cowboy, "~> 2.5"}, + # github.com/dwyl/auth_plug {:auth_plug, "~> 1.5"}, # github.com/dwyl/useful diff --git a/mix.lock b/mix.lock index fef74fbc..01a13ebd 100644 --- a/mix.lock +++ b/mix.lock @@ -9,7 +9,7 @@ "db_connection": {:hex, :db_connection, "2.4.3", "3b9aac9f27347ec65b271847e6baeb4443d8474289bd18c1d6f4de655b70c94d", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c127c15b0fa6cfb32eed07465e05da6c815b032508d4ed7c116122871df73c12"}, "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, "ecto": {:hex, :ecto, "3.9.4", "3ee68e25dbe0c36f980f1ba5dd41ee0d3eb0873bccae8aeaf1a2647242bffa35", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "de5f988c142a3aa4ec18b85a4ec34a2390b65b24f02385c1144252ff6ff8ee75"}, - "ecto_sql": {:hex, :ecto_sql, "3.9.2", "34227501abe92dba10d9c3495ab6770e75e79b836d114c41108a4bf2ce200ad5", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1eb5eeb4358fdbcd42eac11c1fbd87e3affd7904e639d77903c1358b2abd3f70"}, + "ecto_sql": {:hex, :ecto_sql, "3.9.2", "34227501abe92dba10d9c3495ab6770e75e79b836d114c41108a4bf2ce200ad5", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1eb5eeb4358fdbcd42eac11c1fbd87e3affd7904e639d77903c1358b2abd3f70"}, "envar": {:hex, :envar, "1.1.0", "105bcac5a03800a1eb21e2c7e229edc687359b0cc184150ec1380db5928c115c", [:mix], [], "hexpm", "97028ab4a040a5c19e613fdf46a41cf51c6e848d99077e525b338e21d2993320"}, "esbuild": {:hex, :esbuild, "0.6.0", "9ba6ead054abd43cb3d7b14946a0cdd1493698ccd8e054e0e5d6286d7f0f509c", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "30f9a05d4a5bab0d3e37398f312f80864e1ee1a081ca09149d06d474318fd040"}, "excoveralls": {:hex, :excoveralls, "0.15.1", "83c8cf7973dd9d1d853dce37a2fb98aaf29b564bf7d01866e409abf59dac2c0e", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f8416bd90c0082d56a2178cf46c837595a06575f70a5624f164a1ffe37de07e7"}, @@ -21,10 +21,10 @@ "heroicons": {:hex, :heroicons, "0.5.2", "a7ae72460ecc4b74a4ba9e72f0b5ac3c6897ad08968258597da11c2b0b210683", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "7ef96f455c1c136c335f1da0f1d7b12c34002c80a224ad96fc0ebf841a6ffef5"}, "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, "joken": {:hex, :joken, "2.5.0", "09be497d804b8115eb6f07615cef2e60c2a1008fb89dc0aef0d4c4b4609b99aa", [:mix], [{:jose, "~> 1.11.2", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "22b25c89617c5ed8ca7b31026340a25ea0f9ca7160f9706b79be9ed81fdf74e7"}, - "jose": {:hex, :jose, "1.11.2", "f4c018ccf4fdce22c71e44d471f15f723cb3efab5d909ab2ba202b5bf35557b3", [:mix, :rebar3], [], "hexpm", "98143fbc48d55f3a18daba82d34fe48959d44538e9697c08f34200fa5f0947d2"}, + "jose": {:hex, :jose, "1.11.5", "3bc2d75ffa5e2c941ca93e5696b54978323191988eb8d225c2e663ddfefd515e", [:mix, :rebar3], [], "hexpm", "dcd3b215bafe02ea7c5b23dafd3eb8062a5cd8f2d904fd9caa323d37034ab384"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, @@ -46,7 +46,6 @@ "postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, - "swoosh": {:hex, :swoosh, "1.9.1", "0a5d7bf9954eb41d7e55525bc0940379982b090abbaef67cd8e1fd2ed7f8ca1a", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "76dffff3ffcab80f249d5937a592eaef7cc49ac6f4cdd27e622868326ed6371e"}, "tailwind": {:hex, :tailwind, "0.1.9", "25ba09d42f7bfabe170eb67683a76d6ec2061952dc9bd263a52a99ba3d24bd4d", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "9213f87709c458aaec313bb5f2df2b4d2cedc2b630e4ae821bf3c54c47a56d0b"}, "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, diff --git a/test/app/todo_test.exs b/test/app/todo_test.exs index 82e93de9..2eb5bf58 100644 --- a/test/app/todo_test.exs +++ b/test/app/todo_test.exs @@ -9,6 +9,7 @@ defmodule App.TodoTest do import App.TodoFixtures @invalid_attrs %{person_id: nil, status: nil, text: nil} + @valid_attrs %{person_id: 42, status: 0, text: "some text"} test "list_items/0 returns all items" do item = item_fixture() @@ -21,10 +22,8 @@ defmodule App.TodoTest do end test "create_item/1 with valid data creates a item" do - valid_attrs = %{person_id: 0, status: 0, text: "some text"} - - assert {:ok, %Item{} = item} = Todo.create_item(valid_attrs) - assert item.person_id == 0 + assert {:ok, %Item{} = item} = Todo.create_item(@valid_attrs) + assert item.person_id == 42 assert item.status == 0 assert item.text == "some text" end @@ -35,11 +34,11 @@ defmodule App.TodoTest do test "update_item/2 with valid data updates the item" do item = item_fixture() - update_attrs = %{person_id: 1, status: 1, text: "some updated text"} + update_attrs = %{person_id: 43, status: 2, text: "some updated text"} assert {:ok, %Item{} = item} = Todo.update_item(item, update_attrs) - assert item.person_id == 1 - assert item.status == 1 + assert item.person_id == 43 + assert item.status == 2 assert item.text == "some updated text" end diff --git a/test/app_web/controllers/api_controller_test.exs b/test/app_web/controllers/api_controller_test.exs new file mode 100644 index 00000000..278a17cb --- /dev/null +++ b/test/app_web/controllers/api_controller_test.exs @@ -0,0 +1,87 @@ +defmodule AppWeb.ApiControllerTest do + use AppWeb.ConnCase + alias App.Todo + + @create_attrs %{person_id: 42, status: 0, text: "some text"} + @update_attrs %{person_id: 43, status: 0, text: "some updated text"} + @update_status_attrs %{status: 1} + @invalid_attrs %{person_id: nil, status: nil, text: nil} + @invalid_status_attrs %{status: 6} + + describe "list" do + test "all items", %{conn: conn} do + {:ok, _item} = Todo.create_item(@create_attrs) + + conn = get(conn, ~p"/api/items") + + assert conn.status == 200 + assert length(Jason.decode!(response(conn, 200))) == 0 + end + end + + describe "create" do + test "a valid item", %{conn: conn} do + conn = post(conn, ~p"/api/items", @create_attrs) + + assert conn.status == 200 + assert Map.get(Jason.decode!(response(conn, 200)), :text) == Map.get(@create_attrs, "text") + + assert Map.get(Jason.decode!(response(conn, 200)), :status) == + Map.get(@create_attrs, "status") + + assert Map.get(Jason.decode!(response(conn, 200)), :person_id) == + Map.get(@create_attrs, "person_id") + end + + test "an invalid item", %{conn: conn} do + conn = post(conn, ~p"/api/items", @invalid_attrs) + + assert conn.status == 400 + + error_text = response(conn, 400) |> Jason.decode!() |> Map.get("text") + assert error_text == ["can't be blank"] + end + end + + describe "update" do + test "item with valid attributes", %{conn: conn} do + {:ok, item} = Todo.create_item(@create_attrs) + + conn = put(conn, ~p"/api/items/#{item.id}", @update_attrs) + assert conn.status == 200 + assert Map.get(Jason.decode!(response(conn, 200)), :text) == Map.get(@update_attrs, "text") + end + + test "item with invalid attributes", %{conn: conn} do + {:ok, item} = Todo.create_item(@create_attrs) + + conn = put(conn, ~p"/api/items/#{item.id}", @invalid_attrs) + + assert conn.status == 400 + error_text = response(conn, 400) |> Jason.decode!() |> Map.get("text") + assert error_text == ["can't be blank"] + end + end + + describe "update item status" do + test "with valid attributes", %{conn: conn} do + {:ok, item} = Todo.create_item(@create_attrs) + + conn = put(conn, ~p"/api/items/#{item.id}/status", @update_status_attrs) + assert conn.status == 200 + + assert Map.get(Jason.decode!(response(conn, 200)), :status) == + Map.get(@update_status_attrs, "status") + end + + test "with invalid attributes", %{conn: conn} do + {:ok, item} = Todo.create_item(@create_attrs) + + conn = put(conn, ~p"/api/items/#{item.id}/status", @invalid_status_attrs) + + assert conn.status == 400 + error_text = response(conn, 400) |> Jason.decode!() |> Map.get("status") + assert error_text == ["must be less than or equal to 2"] + end + end +end