Todo App with Cleanup using Elixir/Phoenix, GraphQL and Postgres
To start your Phoenix server:
- Install dependencies with
mix deps.get
- Create and migrate your database with
mix ecto.setup
- Execute testcases with
mix test
- Start Phoenix endpoint with
mix phx.server
or inside IEx withiex -S mix phx.server
Now you can visit localhost:4000/api/graphiql
from your browser on dev envirnment.For production use localhost:4000/api/
Ready to run in production? Please check our deployment guides.
- Elixir 1.14.3
- Erlang OTP 24.0.4
- Postgres
-
create the project
mix phx.new todo --no-html --no-assets
Note: Reply 'Y' fetch and install dependencies.
cd todo
-
Go to these two files and update Todo.Repo database credentials for postgres:
config/dev.exs config/test.exs
-
create the db using mix command:
mix ecto.create
-
generate schemas, and migrations for the
List
resourcemix phx.gen.schema List lists --binary-id title:string archived:boolean
-
generate schemas, and migrations for the
Item
resourcemix phx.gen.schema Item items --binary-id list_id:references:lists --binary-id content:string completed:boolean
-
migrate the database
mix ecto.migrate
-
add below code to seeds file
priv/repo/seeds.exs
:alias Todo.List alias Todo.Item alias Todo.Repo # Adding data to List and Item %{id: list_id} = %List{title: "List 1"} |> Repo.insert! %Item{content: "List 1 Item 1", list_id: list_id} |> Repo.insert! %{id: list_id} = %List{title: "List 2"} |> Repo.insert! %Item{content: "List 2 Item 2", list_id: list_id} |> Repo.insert!
and execute seeds to populate seeding data using mix command
mix run priv/repo/seeds.exs
-
Add below Absinthe dependencies for Graphql integration in
mix.exs
file inside deps():{:absinthe, "~> 1.6"}, {:absinthe_plug, "~> 1.5"}
-
Replace scope api code:
scope "/api", TodoWeb do pipe_through :api end
with
scope "/api" do pipe_through :api if Mix.env == :dev do forward "/graphiql", Absinthe.Plug.GraphiQL, schema: TodoWeb.Schema end forward "/", Absinthe.Plug, schema: TodoWeb.Schema end
-
Add Graphql schema file inside todo_web to import queries and mutation along with middleware to handle changeset error:
todo/lib/todo_web/schema.ex
defmodule TodoWeb.Schema do use Absinthe.Schema alias SeWeb.GraphQL.Middleware import_types TodoWeb.Graphql.SchemaTypes query do import_fields :schema_queries end mutation do import_fields :schema_mutations end # exectute changeset error middleware for each mutation def middleware(middleware, _field, %{identifier: :mutation}) do middleware ++ [Middleware.ChangesetErrors] end def middleware(middleware, _field, _object) do middleware end end
-
Add graphql schema types and resolvers file seperately:
todo/lib/todo_web/graphql/schema_types.ex
defmodule TodoWeb.Graphql.SchemaTypes do use Absinthe.Schema.Notation alias TodoWeb.Graphql.SchemaResolver object :schema_queries do @desc "Get all lists" field :all_lists, non_null(list_of(non_null(:list))) do resolve(&SchemaResolver.all_lists/3) end @desc "Get all list items" field :all_list_items, non_null(list_of(non_null(:item))) do arg :list_id, non_null(:id) resolve(&SchemaResolver.all_list_items/3) end end object :schema_mutations do field :create_list, non_null(list_of(non_null(:list))) do arg :title, non_null(:string) resolve(&SchemaResolver.create_list/3) end field :update_list, non_null(list_of(non_null(:list))) do arg :id, non_null(:id) arg :title, :string arg :archived, :boolean resolve(&SchemaResolver.update_list/3) end field :create_list_item, non_null(:item) do arg :content, non_null(:string) arg :list_id, non_null(:id) resolve(&SchemaResolver.create_list_item/3) end field :update_list_item, non_null(:item) do arg :id, non_null(:id) arg :content, :string arg :completed, :boolean arg :list_id, :id resolve(&SchemaResolver.update_list_item/3) end end object :list do field :id, non_null(:id) field :title, non_null(:string) field :archived, non_null(:boolean) end object :item do field :id, non_null(:id) field :content, non_null(:string) field :completed, non_null(:boolean) field :list_id, non_null(:id) end end
todo/lib/todo_web/graphql/schema_resolvers.ex
defmodule TodoWeb.Graphql.SchemaResolver do alias Todo.Lists alias Todo.Items def all_lists(_root, _args, _info) do {:ok, Lists.list_lists()} end def create_list(_parent, args, _info) do Lists.create_list(args) end def update_list(_parent, %{id: list_id} = args, _info) do Lists.get_list(list_id) |> case do nil -> {:error, "List with id '#{list_id}' doesn't exist"} record -> Lists.update_list(record, args) end end def delete_list(_parent, %{id: list_id} = args, _info) do Lists.get_list(list_id) |> case do nil -> {:error, "List with id '#{list_id}' doesn't exist"} record -> Lists.delete_list(record) end end def all_list_items(_root, %{list_id: list_id} = _args, _info) do {:ok, Items.list_items(list_id)} end def create_list_item(_parent, args, _info) do Items.create_list_item(args) end def update_list_item(_parent, %{id: item_id} = args, _info) do Items.get_list_item(item_id) |> case do nil -> {:error, "Item with id '#{item_id}' doesn't exist"} record -> Items.update_list_item(record, args) end end def delete_list_item(_parent, %{id: item_id} = _args, _info) do Items.get_list_item(item_id) |> case do nil -> {:error, "Item with id '#{item_id}' doesn't exist"} record -> Items.delete_list_item(record) end end end
-
Create List and Item implementation files inside todo folder:
todo/lib/todo/lists.ex
defmodule Todo.Lists do @moduledoc """ List related CRUDS. """ import Ecto.Query, warn: false alias Todo.Repo alias Todo.List require Logger @doc """ Returns all the lists. """ def list_lists do Repo.all(List) end @doc """ Gets a single list. """ def get_list(id), do: Repo.get(List, id) @doc """ Creates a list. """ def create_list(attrs \\ %{}) do %List{} |> List.changeset(attrs) |> Repo.insert() end @doc """ Updates a list. """ def update_list(%List{} = list, attrs) do list |> List.update_changeset(attrs) |> Repo.update end @doc """ Deletes a List. """ def delete_list(%List{} = record) do Repo.delete(record) end end
todo/lib/todo/items.ex
defmodule Todo.Items do @moduledoc """ The Items context. """ import Ecto.Query, warn: false alias Todo.Repo alias Todo.Item require Logger @doc """ Returns all the items of list by list id. """ def list_items(id) do query = from i in Item, where: i.list_id == ^id Repo.all(query) end @doc """ Get a single item by id. """ def get_list_item(id), do: Repo.get(Item, id) @doc """ Create an item of list. """ def create_list_item(attrs \\ %{}) do %Item{} |> Item.changeset(attrs) |> Repo.insert(returning: true) end @doc """ Updates a item. """ def update_list_item(%Item{} = record, attrs) do record |> Item.update_changeset(attrs) |> Repo.update(returning: true) end @doc """ Updates a item. """ def update_item(%Item{} = item, attrs) do item = Repo.get!(Item, item.id) Repo.update(item, attrs) end @doc """ Deletes an Item. """ def delete_list_item(%Item{} = record) do Repo.delete(record) end end
-
Move database schema inside schema folder: i.e move
todo/lib/todo/list.ex
totodo/lib/todo/schema/list.ex
movetodo/lib/todo/item.ex
totodo/lib/todo/schema/item.ex
-
Add Changeset Error Handler Middleware inside graphql/middleware folder:
todo/lib/todo_web/graphql/middleware/changeset_errors.ex
:defmodule SeWeb.GraphQL.Middleware.ChangesetErrors do @behaviour Absinthe.Middleware def call(resolution, _) do %{resolution | errors: Enum.flat_map(resolution.errors, &handle_error/1) } end defp handle_error(%Ecto.Changeset{} = changeset) do changeset |> Ecto.Changeset.traverse_errors(fn {err, _opts} -> err end) |> Enum.map(fn({k,v}) -> "#{k}: #{v}" end) end defp handle_error(error), do: [error] end
-
Add GenServer for auto-cleanup in worker folder:
todo/lib/todo/worker/cleanup.ex
:defmodule Todo.Cleanup do use GenServer import Ecto.Query, warn: false alias Todo.Repo alias Todo.List require Logger @schedule_time_value 5 * 60 * 1000 # 5 Minutes def start_link(_) do GenServer.start_link(__MODULE__, %{}) end @impl true def init(state) do Logger.info("#{__MODULE__} worker started") cleanup() # Schedule work to be performed on start schedule_work() {:ok, state} end @impl true def handle_info(:work, state) do # Do the desired cleanup here cleanup() # Reschedule once more schedule_work() {:noreply, state} end defp schedule_work do # We schedule the cleanup to happen in 5 minutes (written in milliseconds). Process.send_after(self(), :work, @schedule_time_value) end defp cleanup() do Logger.debug "Cleanup executing at '#{NaiveDateTime.utc_now()}'..." last_24_hours = NaiveDateTime.add(NaiveDateTime.utc_now(), -1 * 86_400) # Last 24 hours from(l in List, where: l.updated_at <= ^last_24_hours and l.archived==^false) |> Repo.update_all(set: [archived: true]) |> case do {0, _} -> Logger.debug "Cleaup executed. No new unarchived records found that are not updated in last 24 hours." {updated_count, _} -> Logger.debug "Cleaup completed. '#{inspect updated_count}' records archived successully." end end end
-
Add below line at the end of the supervision tree inside childeren list:
todo/lib/todo/application.ex
# Starts a cleanup worker by calling: Todo.Cleanup.start_link(arg) {Todo.Cleanup, []}
-
Add reasonable amount of testcases:
todo/test/todo/items_test.exs
todo/test/todo/lists_test.exs