Skip to content

Instantly share code, notes, and snippets.

@aaronjensen
Last active October 27, 2022 20:07
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save aaronjensen/2b6056ee5d99ebcae0d08886864be3e1 to your computer and use it in GitHub Desktop.
Save aaronjensen/2b6056ee5d99ebcae0d08886864be3e1 to your computer and use it in GitHub Desktop.
defmodule Executable do
@moduledoc """
Executables are either commands or queries.
They should declare a struct that dictates the shape and defaults of their
parameters and a `handle_execute/1` function that takes that struct and does
something in response.
"""
use Behaviour
alias Ecto.Changeset
@type t :: map
@callback new(%{binary => term} | %{atom => term}) :: Ecto.Changeset.t | no_return
@type handle_execute_error_response :: {:error, String.t} | {:error, %{errors: Enum.t}}
@callback handle_execute(t) :: {:ok, any} | handle_execute_error_response
@callback handle_authorize(t) :: boolean
defmacro __using__(_options) do
quote do
@behaviour Executable
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
import Executable
def handle_authorize(_), do: true
defoverridable [handle_authorize: 1]
end
end
@spec execute(Ecto.Changeset.t) :: {:ok, any} | {:error, any}
def execute(%{valid?: false} = changeset) do
{:error, changeset}
end
def execute(%{valid?: true, data: %module{}} = changeset) do
applied_changeset = shallow_apply_changes(changeset)
if module.handle_authorize(applied_changeset) do
module.handle_execute(applied_changeset)
else
{:error, :unauthorized}
end
end
@doc """
Apply changes only to the executable. This allows changesets that the
executable wraps to remain changesets.
"""
def shallow_apply_changes(%Changeset{changes: changes, data: data, types: types}) do
Enum.reduce(changes, data, fn {key, value}, acc ->
case Map.fetch(types, key) do
{:ok, _} ->
Map.put(acc, key, value)
:error ->
acc
end
end)
end
end
defmodule Executable.Controller do
@moduledoc """
Make a controller into a single action controller that converts params into an
Executable, executes it and then converst the result back to an http response.
"""
@callback handle_success(Plug.Conn.t, Plug.Conn.params, {:ok, any}) :: Plug.Conn.t
@callback handle_error(Plug.Conn.t, Plug.Conn.params, {:error, any}) :: Plug.Conn.t
defmacro __using__(_options) do
quote do
import Phoenix.Controller
import Plug.Conn
@behaviour Executable.Controller
def execute(conn, params) do
executable = executable(conn, params)
result = conn.private[:executable_result] ||
Executable.execute(executable)
case result do
{:ok, _} -> handle_success(conn, params, result)
{:error, :unauthorized} -> handle_unauthorized(conn, params)
{:error, _} -> handle_error(conn, params, result)
end
end
def handle_unauthorized(conn, _params) do
conn
|> put_status(:forbidden)
|> render(Executable.View, "error.json", errors: "Unauthorized")
end
def handle_error(conn, _params, {:error, errors}) do
conn
|> put_status(:bad_request)
|> render(Executable.View, "error.json", errors: errors)
end
defoverridable [handle_unauthorized: 2]
defoverridable [handle_error: 3]
end
end
end
defmodule App.Queries.GetSchools do
@moduledoc """
Query that fetches a list of school districts
"""
import Ecto.Query
alias App.{Repo, School}
use Executable
@type t :: %__MODULE__{
district_id: String.t,
q: String.t,
value: String.t,
}
embedded_schema do
field :district_id, :string
field :q, :string
field :value, :string
end
def new(params) do
%__MODULE__{}
|> cast(params, ~w(q district_id value))
|> validate_required([:district_id])
end
def handle_execute(params) do
query =
School
|> for_district(params)
|> by_name_or_id(params)
|> limit(25)
{:ok, Repo.all(query)}
end
defp for_district(base_query, %{district_id: district_id}) do
from s in base_query, where: s.district_id == ^district_id
end
defp by_name_or_id(base_query, %{value: nil, q: nil}), do: base_query
defp by_name_or_id(base_query, %{value: value, q: nil}) do
from s in base_query, where: s.id == ^value
end
defp by_name_or_id(base_query, %{value: nil, q: q}) do
from s in base_query, where: ilike(s.name, ^("%#{q}%"))
end
defp by_name_or_id(base_query, %{value: value, q: q}) do
from s in base_query,
where: ilike(s.name, ^("%#{q}%")) or s.id == ^value
end
end
defmodule App.SchoolController do
use App.Web, :executable_controller
alias App.Queries.GetSchools
@cache_in_seconds 60 * 15 # 15 mins
plug App.Plug.CacheControl, max_age: @cache_in_seconds
def executable(_conn, params), do: GetSchools.new(params)
def handle_success(conn, _params, {:ok, results}) do
conn
|> put_status(:ok)
|> assign(:results, results)
|> render(App.SchoolCatalogView, "list.json")
end
end
defmodule App.SchoolControllerTest do
use App.ConnCase, async: true
test "fetching schools for a district" do
school = build(:school)
conn =
build_conn
|> stub_executable_result({:ok, [school]})
|> get("/api/v1/schools")
result = json_response(conn, 200)
assert result == %{
"results" => [
%{
"name" => school.name,
"id" => school.id,
}
],
"count" => 1,
}
end
test "failed to fetch school districts" do
conn =
build_conn
|> stub_executable_result({:error, "some-error"})
|> get("/api/v1/schools")
result = json_response(conn, 400)
assert result == %{"_error" => "some-error"}
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment