Design pattern parameter
Sometimes we need to unify data that we get from external services. For example, we have two different versions of endpoint but under the hood we need to use the same services. Also, we want to check external data types, validate data especially if we are talking about non database validation, and calculate or execute something that prepares data before sending it to services. This pattern has common principles like Protocol Buffers and XML where we also have mechanisms to describe schema and set data types, but inside the well known Phoenix framework.
To show this pattern in action, we create a simple application, https://github.com/sergey-chechaev/example_app, where we have endpoint Payments for obtaining payment information from an external system.
So lats generates the controller, view, and context for a Payment JSON resource.
mix phx.gen.json Payments Payment payment_logs \
sum:decimal bonus:decimal tax:decimal \
operation_type:enum:refill:withdrawal \
status:enum:initial:processed:rejected \
comment:text pay_period_from:utc_datetime \
pay_period_to:utc_datetime
Imagine that the external service sends data for us to create Payment
curl -X POST \
'http://localhost:4000/api/payments?sum=100&operation_type=refill&pay_period_from=2022-10-01&comment=comment'
We get the errors
{
"errors": {
"bonus": [
"can't be blank"
],
"pay_period_from": [
"is invalid"
],
"pay_period_to": [
"can't be blank"
],
"status": [
"can't be blank"
],
"tax": [
"can't be blank"
]
}
}
The problem here is that we must send other required attributes, but what if we need to calculate status
, tax
, bonus
, pay_period_to
itself, format pay_period_from
and create a record in another table. But at the same time, we don’t want to change the changeset and context. There may be many variants to solve this issue, let’s look at Pattern Parameter.
Firstly, we need to create special interface params such as controller, view, changeset and so on. Inside entrypoint lib/example_app_web.ex add new function params, the definition of this function will be executed for every params interface.
def params do
quote do
use Ecto.Schema
import Ecto.Changeset
import ExampleAppWeb.Gettext
@primary_key false
def fetch(term, key) do
term
|> Map.from_struct()
|> Map.fetch(String.to_existing_atom(key))
end
end
end
– Ecto.Schema
for maps external data into Elixir structs.
– Ecto.Changeset
for filtering, casting, validation and definition of constraints when manipulating structs.
– ExampleAppWeb.Gettext
for based API for working with internationalized applications.
– @primary_key false
to disable generation of additional primary key fields.
– fetch
is the function for fetching data from struct. We can add other functions here if we need to.
Secondly, we add new folder params where we will save our params modules: mkdir lib/example_app_web/params
Then create the first params module Params.External.Payment
We use external scope to show that we can use different formatting for different endpoints:
mkdir lib/example_app_web/params/external/ && touch lib/example_app_web/params/external/payment.ex
And add code to it:
defmodule Params.External.Payment do
@moduledoc """
Resolves validation params for external payment creation
"""
use ExampleAppWeb, :params
alias ExampleApp.Payments.Payment
alias Ecto.Enum
@required [:operation_type, :sum]
embedded_schema do
field :bonus, :decimal, default: 0
field :comment, :string
field :operation_type, Enum, values: Enum.values(Payment, :operation_type)
field :status, Enum, values: Enum.values(Payment, :status)
field :pay_period_from, :utc_datetime
field :pay_period_to, :utc_datetime
field :sum, :decimal
field :tax, :decimal, default: 0
end
def prepare(:create, params) do
params
|> create_changeset()
|> apply_action(:insert)
end
defp create_changeset(params) do
%__MODULE__{}
|> cast(params, [
:operation_type,
:status,
:sum,
:bonus,
:pay_period_from,
:pay_period_to,
:tax
])
|> validate_required(@required)
end
end
For this external endpoint, we only need :operation_type and :sum.
–use ExampleAppWeb, :params
here we add our new function that we define inside the entrypoint.
–embedded_schema
to validate data, set data type and show how the data is represented in your applications.
– prepare(:create, params)
is the entrypoint to execute this module inside the controller for the action create.
– create_changeset
for casting parameters and validation.
Now execute this module and see how it works:
iex(1)> Params.External.Payment.prepare(:create, %{operation_type: :refill, sum: 100})
{:ok,
%Params.External.Payment{
bonus: 0,
comment: nil,
operation_type: :refill,
pay_period_from: nil,
pay_period_to: nil,
status: nil,
sum: #Decimal<100>,
tax: 0
}}
As a result, we get what we need for this external endpoint, only two requirement fields were transferred. But we can’t send this struct to module Payment because we need to calculate other requirement fields for this. Let’s do that.
def prepare(:create, params) do
params
|> cast_start_pay_period()
|> cast_end_pay_period()
|> maybe_add_bonus_sum()
|> add_tax()
|> fetch_status()
|> create_changeset()
|> apply_action(:insert)
end
defp cast_start_pay_period(params) do
pay_period_from =
Timex.today()
|> Timex.to_datetime()
Map.put(params, "pay_period_from", pay_period_from)
end
def cast_end_pay_period(params) do
pay_period_to =
Timex.today()
|> Timex.end_of_month()
|> Timex.to_datetime()
Map.put(params, "pay_period_to", pay_period_to)
end
defp maybe_add_bonus_sum(
%{
operation_type: :refill,
sum: sum
} = params
) do
Map.put(params, "bonus", Payment.calculate_bonus(sum))
end
defp maybe_add_bonus_sum(params), do: params
defp add_tax(%{sum: sum, operation_type: :refill} = params) do
Map.put(params, "tax", Payment.calculate_tax(sum))
end
defp add_tax(params), do: params
def fetch_status(params) do
{:ok, status} = fetch(Payment, "status")
Map.put(params, "status", status)
end
We also need to add the functions calculate_bonus
and calculate_tax
to the Payment Changeset:
defmodule ExampleApp.Payments.Payment do
use Ecto.Schema
import Ecto.Changeset
@tax_percent 0.1
schema "payment_logs" do
field :bonus, :decimal
field :comment, :string
field :operation_type, Ecto.Enum, values: [:refill, :withdrawal]
field :pay_period_from, :utc_datetime
field :pay_period_to, :utc_datetime
field :status, Ecto.Enum, values: [:initial, :processed, :rejected], default: :initial
field :sum, :decimal
field :tax, :decimal
timestamps()
end
@doc false
def changeset(payment, attrs) do
payment
|> cast(attrs, [:sum, :bonus, :tax, :operation_type, :status, :comment, :pay_period_from, :pay_period_to])
|> validate_required([:sum, :bonus, :tax, :operation_type, :status, :pay_period_from, :pay_period_to])
end
def calculate_bonus(sum) do
sum = Decimal.new(sum) |> Decimal.to_float()
case sum do
n when n >= 100 ->
2
_ ->
0
end
end
def calculate_tax(sum) do
sum = Decimal.new(sum) |> Decimal.to_float()
sum * @tax_percent
end
end
Then execute Params.External.Payment
inside Controller:
defmodule ExampleAppWeb.PaymentController do
use ExampleAppWeb, :controller
alias ExampleApp.Payments
alias ExampleApp.Payments.Payment
alias Params.External.Payment, as: PaymentParams
action_fallback ExampleAppWeb.FallbackController
def create(conn, payment_params) do
with {:ok, prepared_params} <- PaymentParams.prepare(:create, payment_params),
params <- Map.from_struct(prepared_params),
{:ok, %Payment{} = payment} <- Payments.create_payment(params) do
conn
|> put_status(:created)
|> put_resp_header("location", Routes.payment_path(conn, :show, payment))
|> render("show.json", payment: payment)
end
end
end
Now we can use the same Payment module for different Params modules and endpoints. For example: we separate external and internal endpoints. Change router.ex by adding internal and external scopes:
scope "/external", ExampleAppWeb.External do
pipe_through :api
resources "/payments", PaymentController
end
scope "/internal", ExampleAppWeb.Internal do
pipe_through :api
resources "/payments", PaymentController
end
Add a controller and add a different Params module to it where there are different required fields.
External Controller with External Payment Params
mkdir lib/example_app_web/controller/external/ && touch lib/example_app_web/controller/external/payment_controller.ex
defmodule ExampleAppWeb.External.PaymentController do
use ExampleAppWeb, :controller
alias ExampleApp.Payments
alias ExampleApp.Payments.Payment
alias Params.External.Payment, as: PaymentParams
def create(conn, payment_params) do
with {:ok, prepared_params} <- PaymentParams.prepare(:create, payment_params),
params <- Map.from_struct(prepared_params),
{:ok, %Payment{} = payment} <- Payments.create_payment(params) do
conn
|> put_status(:created)
|> put_resp_header("location", Routes.payment_path(conn, :show, payment))
|> render(ExampleAppWeb.PaymentView, "show.json", payment: payment)
end
end
end
Internal Controller with Internal Payment Params
mkdir lib/example_app_web/controller/internal/ && touch lib/example_app_web/controller/internal/payment_controller.ex
defmodule ExampleAppWeb.Internal.PaymentController do
use ExampleAppWeb, :controller
alias ExampleApp.Payments
alias ExampleApp.Payments.Payment
alias Params.Internal.Payment, as: PaymentParams
def create(conn, payment_params) do
with {:ok, prepared_params} <- PaymentParams.prepare(:create, payment_params),
params <- Map.from_struct(prepared_params),
{:ok, %Payment{} = payment} <- Payments.create_payment(params) do
conn
|> put_status(:created)
|> put_resp_header("location", Routes.payment_path(conn, :show, payment))
|> render(ExampleAppWeb.PaymentView, "show.json", payment: payment)
end
end
end
And finally create the Internal Payment Params module with different data logic
mkdir lib/example_app_web/params/internal/ && touch lib/example_app_web/params/internal/payment.ex
defmodule Params.Internal.Payment do
@moduledoc """
Resolves validation params for internal payment creation
"""
use ExampleAppWeb, :params
alias ExampleApp.Payments.Payment
alias Ecto.Enum
@required [:operation_type, :sum, :bonus, :tax, :comment]
embedded_schema do
field :bonus, :decimal
field :comment, :string
field :operation_type, Enum, values: Enum.values(Payment, :operation_type)
field :status, Enum, values: Enum.values(Payment, :status)
field :pay_period_from, :utc_datetime
field :pay_period_to, :utc_datetime
field :sum, :decimal
field :tax, :decimal
end
def prepare(:create, params) do
params
|> cast_start_pay_period()
|> cast_end_pay_period()
|> fetch_status()
|> create_changeset()
|> apply_action(:insert)
end
defp cast_start_pay_period(params) do
pay_period_from =
Timex.today()
|> Timex.to_datetime()
Map.put(params, "pay_period_from", pay_period_from)
end
def cast_end_pay_period(params) do
pay_period_to =
Timex.today()
|> Timex.end_of_month()
|> Timex.to_datetime()
Map.put(params, "pay_period_to", pay_period_to)
end
def fetch_status(params) do
{:ok, status} = fetch(Payment, "status")
Map.put(params, "status", status)
end
defp create_changeset(params) do
%__MODULE__{}
|> cast(params, [
:operation_type,
:status,
:sum,
:bonus,
:pay_period_from,
:pay_period_to,
:tax,
:comment
])
|> validate_required(@required)
end
end
Conclusion
We use different endpoints and different Params modules, but under the hood we use the same Payment context. This approach encapsulates parameters logic, helps us validate and prepare data for our controller and separate responsibility between our incoming data and business logic. It also helps us prepare data for complex service where we use more than one context. We will see this when we create a service based on the pattern Railway Oriented Programming — Part 2.