Skip to content

Instantly share code, notes, and snippets.

@danhawkins
Created September 7, 2022 04:31
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save danhawkins/35e6720254c68b388e132e658f132ccd to your computer and use it in GitHub Desktop.
Save danhawkins/35e6720254c68b388e132e658f132ccd to your computer and use it in GitHub Desktop.
Command macro
defmodule Cmd.Command do
@moduledoc """
Utility for command definition, wrapping a design pattern into code
For our commands we will used type_embedded_schema to design an in memory schema
which will be used for the command payload
As well as this we require the developer to implement a changeset/2 function
"""
@type struct_or_map :: struct() | map()
@callback changeset(schema :: struct(), attrs :: struct_or_map) :: Ecto.Changeset.t()
defmacro __using__(_) do
module_name = __CALLER__.module
quote do
@module_name unquote(module_name)
import Ecto.Changeset
import Utils.ConvertStructs, only: [convert_to_map: 1]
import TypedEctoSchema,
only: [
typed_embedded_schema: 1,
typed_embedded_schema: 2
]
@behaviour Cmd.Command
# We never want a default PK in command
@primary_key false
use Ecto.Schema
def cast(attrs) do
safe_changeset(attrs) |> apply_changes()
end
def changeset(attrs) do
struct(@module_name, %{}) |> changeset(attrs)
end
def safe_changeset(attrs) do
struct(@module_name, %{}) |> safe_changeset(attrs)
end
def safe_changeset(schema, attrs) do
map_attrs = convert_to_map(attrs)
changeset(schema, map_attrs)
end
end
end
end
defmodule Utils.ConvertStructs do
@moduledoc """
Utilities to conver structs into map and vice versa (specifically for moving maps to ecto changesets)
"""
def convert_to_struct(struct_name, map) do
struct(struct_name, map)
end
def convert_to_map(%model{__schema__: _} = schema) do
new_map = Map.take(schema, model.__schema__(:fields))
convert_to_map(new_map)
end
def convert_to_map(schema) when is_struct(schema) do
new_map = Map.from_struct(schema)
convert_to_map(new_map)
end
def convert_to_map(schema) when is_map(schema) do
for {k, v} <- schema, into: %{} do
{k, convert_to_map(v)}
end
end
def convert_to_map([h | t] = input) when is_list(input), do: [convert_to_map(h) | convert_to_map(t)]
def convert_to_map(:null), do: nil
def convert_to_map(term), do: term
end
defmodule Ivrl.Tournaments.Commands.ActivateTeam do
@moduledoc """
Updates a teams score for the tournament
"""
use Cmd.Command
typed_embedded_schema do
field(:tournament_id, :string)
field(:sub_tournament_id, :string)
field(:team_id, :string)
embeds_one(:issued_by, User)
end
def changeset(cmd, attrs) do
cmd
|> cast(attrs, [:tournament_id, :sub_tournament_id, :team_id])
|> validate_required([:tournament_id, :team_id])
|> cast_embed(:issued_by, with: &User.changeset/2)
end
end
defmodule Cmd.Middleware.Validate do
@moduledoc """
This commanded middleware requires all commands to be Ecto structs,
this allows commands to have validation setup, if the command failes validation the command will not proceed
and the pipeline will be halted, instead of getting and `:ok` back, you will received a list of validation errors
## Error response example
```
{:error, :validation_failure,
%{
field1: ["can't be blank"],
field2: ["can't be blank"],
}}
## NOTE
We don't handle nested errors here at the moment, although they are inside the changeset
```
"""
@behaviour Commanded.Middleware
alias Commanded.Middleware.Pipeline
import Pipeline
def before_dispatch(%Pipeline{command: %{__struct__: mod} = command} = pipeline) do
if is_ecto?(command) do
case mod.safe_changeset(command) do
%{valid?: false} = changeset -> failed_validation(pipeline, changeset)
changeset -> %{pipeline | command: Ecto.Changeset.apply_changes(changeset)}
end
else
pipeline
end
end
def before_dispatch(%Pipeline{command: _} = pipeline) do
pipeline
end
def after_dispatch(pipeline), do: pipeline
def after_failure(pipeline), do: pipeline
defp is_ecto?(%{__struct__: mod}) do
Kernel.function_exported?(mod, :__changeset__, 0)
end
defp failed_validation(pipeline, %{errors: errors}) do
pipeline
|> respond({:error, :validation_failure, merge_errors(errors)})
|> halt()
end
defp merge_errors(errors) do
Enum.map(errors, fn {field, {msg, _}} ->
{field, [msg]}
end)
|> Enum.into(%{})
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment