Last active
October 27, 2022 20:07
-
-
Save aaronjensen/2b6056ee5d99ebcae0d08886864be3e1 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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