Build your own library to render json response in Phoenix
In my previous article, I introduced my library call JsonView to render json response easier. You can read it here: Render Ecto schema to json with …
Validation is a must have part of web application. You have to validate request parameter before processing, you validate data before inserting to database, and many more.
Normally, I use Ecto.Changeset
to do validation job. But it comes with changeset, I have to build schema, changeset then do the validation. Sometime you just don’t need too much thing like that.
So today we are going to build a simple validation module to use without changeset, or in some project you don’t use Ecto
, or just for learning.
map, list, string
That’s is quite enough, you can define more if you want. And the module will support a simple API to validate a value
1validate(value::any(), validations::keyword()) :: :ok | {:error, String.t()}
Let’s start.
Let’s call our module Checky
. Type check is quite straight forward. Elixir support most of type check guard that we need:
1defmodule Checky do
2 def validate_type(value, :boolean) when is_boolean(value), do: :ok
3 def validate_type(value, :integer) when is_integer(value), do: :ok
4 def validate_type(value, :float) when is_float(value), do: :ok
5 def validate_type(value, :number) when is_number(value), do: :ok
6 def validate_type(value, :string) when is_binary(value), do: :ok
7 def validate_type(value, :binary) when is_binary(value), do: :ok
8 def validate_type(value, :tuple) when is_tuple(value), do: :ok
9 def validate_type(value, :array) when is_list(value), do: :ok
10 def validate_type(value, :list) when is_list(value), do: :ok
11 def validate_type(value, :atom) when is_atom(value), do: :ok
12 def validate_type(value, :function) when is_function(value), do: :ok
13 def validate_type(value, :map) when is_map(value), do: :ok
14 # we will add some more validation here
15 def validate_type(_, type), do: {:error, "is not a #{type}"}
16end
Easy, right? Now let’s support checking for struct
:
1defmodule Checky do
2
3 ...
4 # from Elixir 1.12 you can do this
5 def validate_type(value, struct_name) when is_struct(value, struct_name), do: :ok
6 # this is for Elixir before 1.12
7 def validate_type(%{__struct__: struct}, struct_name) when struct == struct_name, do: :ok
8 ...
9end
keyword
1defmodule Checky do
2 ...
3 # empty list is also a empty keyword
4 def validate_type([] = _check_item, :keyword), do: :ok
5 # if list item is a tuple of 2 and first element is atom then it is a keyword list
6 def validate_type(items, :keyword) when is_list(items) do
7 valid? = Enum.all(item, fn
8 {key, _} when is_atom(key) -> true
9 _ -> false
10 end)
11
12 if valid? do
13 :ok
14 else
15 {:error, "is not a keyword"}
16 end
17 end
18 ...
19end
{:array, type}
which is similar to Ecto.Schema
. 1defmodule Checky do
2 ...
3 def validate_type(value, {:array, type}) when is_list(value) do
4 # We will check type for each value in the list
5 array(value, &validate_type(&1, type))
6 end
7 ...
8 # loop and validate element in array using `validate_func`
9 defp array(data, validate_func)
10
11 defp array([], _) do
12 :ok
13 end
14
15 # validate recursively, and return error if any vadation failed
16 defp array([h | t], validate_func) do
17 case validate_func.(h) do
18 :ok ->
19 array(t, validate_func)
20 err ->
21 err
22 end
23 end
24end
Phew! We have done with type validation. You can add more type validation if you want.
This validation is super easy, Regex
do that for us:
1defmodule Checky end
2 def validate_format(value, check) when is_binary(value) do
3 if Regex.match?(check, value), do: :ok, else: {:error, "does not match format"}
4 end
5
6 def validate_format(_value, _check) do
7 {:error, "format check only support string"}
8 end
9end
These are trivial checks too. Just make sure it is implement Enumerable
protocol.
1defmodule Checky do
2 def validate_inclusion(value, enum) do
3 if Enumerable.impl_for(enum) do
4 if Enum.member?(enum, value) do
5 :ok
6 else
7 {:error, "not be in the inclusion list"}
8 end
9 else
10 {:error, "given condition does not implement protocol Enumerable"}
11 end
12 end
13
14 @doc """
15 Check if value is **not** included in the given enumerable. Similar to `validate_inclusion/2`
16 """
17 def validate_exclusion(value, enum) do
18 if Enumerable.impl_for(enum) do
19 if Enum.member?(enum, value) do
20 {:error, "must not be in the exclusion list"}
21 else
22 :ok
23 end
24 else
25 {:error, "given condition does not implement protocol Enumerable"}
26 end
27 end
28end
This is one of the most complicated part of our module. It’s not difficult, it’s just long. We will support following checks:
equal_to
greater_than_or_equal_to
| min
greater_than
less_than
less_than_or_equal_to
| max
And it should support multiple check like this:
1 validate_number(x, [min: 10, max: 20])
First we code validation function for single condition like this
1 def validate_number(number, {:equal_to, check_value}) do
2 if number == check_value do
3 :ok
4 else
5 {:error, "must be equal to #{check_value}"}
6 end
7 end
As I said, it’s so simple. You can fill the remaining check right? Or you can check the final code at the end of the post. After implementing all validation fucntion for number, it’s time to support multiple condtion check.
1 @spec validate_number(integer() | float(), keyword()) :: :ok | error
2 def validate_number(value, checks) when is_list(checks) do
3 if is_number(value) do
4 checks
5 |> Enum.reduce(:ok, fn
6 check, :ok ->
7 validate_number(value, check)
8
9 _, error ->
10 error
11 end)
12 else
13 {:error, "must be a number"}
14 end
15 end
Length is just a number, so we can reuse number validation. We just have to check if given value is one of support types: list
, map
, string
, and tuple
We will implement get_length/1
function to get data length first.
1 @spec get_length(any) :: pos_integer() | {:error, :wrong_type}
2 defp get_length(param) when is_list(param), do: length(param)
3 defp get_length(param) when is_binary(param), do: String.length(param)
4 defp get_length(param) when is_map(param), do: param |> Map.keys() |> get_length()
5 defp get_length(param) when is_tuple(param), do: tuple_size(param)
6 defp get_length(_param), do: {:error, :wrong_type}
Then we do number validation on the length value
1 @spec validate_length(support_length_types, keyword()) :: :ok | error
2 def validate_length(value, checks) do
3 with length when is_integer(length) <- get_length(value),
4 # validation length number
5 :ok <- validate_number(length, checks) do
6 :ok
7 else
8 {:error, :wrong_type} ->
9 {:error, "length check supports only lists, binaries, maps and tuples"}
10
11 {:error, msg} ->
12 # we prepend length to message return by validation number to get full message
13 # like: "length must be equal to x"
14 {:error, "length #{msg}"}
15 end
16 end
Most of time you want to use multiple valitions on the data. So we will add a function that do multiple validation
We define a simple structure for validation first. This is our validate function spec
1 @spec validate(any(), keyword()) :: :ok | {:error, messages}
Then we can use it like this:
1Checky.validate(value, type: :string, format: ~r/\d\d.+/, length: [min: 8, max: 20])
Validations is a keyword list with short name for validation:
:type
-> validate_type
:format
-> validate_format
:in
-> validate_inclusion
:not_in
-> validate_exclusion
:number
-> validate_number
:length
-> validate_length
Define mapping function:
1 defp get_validator(:type), do: &validate_type/2
2 defp get_validator(:format), do: &validate_format/2
3 defp get_validator(:number), do: &validate_number/2
4 defp get_validator(:length), do: &validate_length/2
5 defp get_validator(:in), do: &validate_inclusion/2
6 defp get_validator(:not_in), do: &validate_exclusion/2
7 defp get_validator(name), do: {:error, "validate_#{name} is not support"}
Go checking validations one by one
1 def validate(value, validators) do
2 do_validate(value, validators, :ok)
3 end
4
5 defp do_validate(_, [], acc), do: acc
6
7 # check validations one by one
8 defp do_validate(value, [h | t] = _validators, acc) do
9 case do_validate(value, h) do
10 :ok -> do_validate(value, t, acc)
11 error -> error
12 end
13 end
14
15 # validate single validation
16 defp do_validate(value, {validator, opts}) do
17 case get_validator(validator) do
18 {:error, _} = err -> err
19 validate_func -> validate_func.(value, opts)
20 end
21 end
Writing a validation module is not so hard. Now you can add more validations to fit your need. As I promised, this is the full source of the module with custom validation fucntion. https://github.com/bluzky/valdi/blob/main/lib/valdi.ex
Thank you for reading to the end of this post. Please leave me a comment.
In my previous article, I introduced my library call JsonView to render json response easier. You can read it here: Render Ecto schema to json with …
When writing API with Phoenix and render json to client, For some fields I want to keep it original value. For some fields, I want to do some …