Flawless is an Elixir library to help validate user input against a schema.
iex> import Flawless.Helpers
iex> schema = %{
...> "username" => string(max_length: 30),
...> "address" => %{
...> "street" => string(),
...> "number" => integer(min: 0, cast_from: :string),
...> "city" => string(),
...> },
...> maybe("interests") => list(string(), min_length: 1)
...> }
iex> value = %{
...> "address" => %{
...> "country" => "Belgium",
...> "number" => "10",
...> "street" => "Main street",
...> "city" => "Brussels"
...> },
...> "interests" => ["programming", "music", :games]
...> }
iex> Flawless.validate(value, schema)
[
%Flawless.Error{
context: [],
message: "Missing required fields: \"username\" (string)."
},
%Flawless.Error{
context: ["address"],
message: "Unexpected fields: [\"country\"]."
},
%Flawless.Error{
context: ["interests", 2],
message: "Expected type: string, got: :games."
}
]
Note: this is a personal project, which I released pretty recently. While it is completed tested, it was never battle-tested. Use it carefully, and don't hesitate to share bugs, ideas and your impressions.
There are already a lot of validation libraries in Elixir. So why write another one?
When I looked for other libraries, I found that there were a few recurrent issues. Namely, poor error messages (not very suitable for user-facing applications), and a cumbersome or inconsistent syntax. While they all have their qualities, I wanted to try out something that was more to my taste.
Also, that was an occasion to learn more about Elixir and experiment.
As much as possible, this library tries to be:
- Consistent: all the helpers provide the same set of common options.
- General: some validation rules might be harder to define than others, but it avoids imposing any restriction.
- Readable: a lot of helpers and shortcuts are provided to make the schemas as simple as possible. The syntax is similar to the syntax of typespecs (when it makes sense) and should feel natural if you're used to it.
- User-friendly: useful errors are returned with clear messages and context, so that it should be easy for a user to understand how to fix the input.
- Modular: schemas are normal Elixir objects and can be easily combined together. No restricting syntax or macro is imposed.
This section is only an overview. For details, see the dedicated Schema definition page.
Schemas are built using helper functions, that internally create more complex structures used during validation. Every data type (string, number, map, list, etc.) has its own helper function.
All helpers support a few common options:
checks
/check
- See Checkslate_checks
/late_check
- See Checksnil
- See Nullable valuescast_from
- See Castingon_error
- See Overriding error messages
The any()
helper is the only helper without a specific type. It can be used
to match literally anything.
Every element can define a series of checks. Each check will evaluate a predicate
on the value and return an error message if it didn't pass. A few built-in rules
are available in Flawless.Rule
though shortcuts are also available for them.
It is also possible to define your own rules easily using the rule/2
helper or
a simple function that returns an :ok
/:error
tuple. For more information, see
the Custom checks page.
Late checks allow to evaluate rules after all other checks have passed. This is useful if the rule should only be evaluated on well-formed data.
# An integer between 0 and 10
integer(checks: [between(0, 10)])
# Accepts only "yes", true, or 1
any(check: one_of(["yes", true, 1]))
# A number that is different from 0
number(check: rule(&(&1 != 0), "The number should be different from zero."))
# The late check never fails because we're sure keys a and b exist
map(
%{a: number(), b: number()},
late_check: rule(fn x -> x.a > x.b end, "a must be bigger than b.")
)
Flawless supports all primitive Elixir types with the functions integer/1
,
float/1
, number/1
, string/1
, boolean/1
, atom/1
, pid/1
, ref/1
,
function/1
and port/1
. Each of them supports specific options, which are
shortcuts to avoid lengthy checks
.
# A non-empty string
string(non_empty: true)
# An integer between 0 and 100
integer(min: 0, max: 100)
Every element supports the nil
boolean option. When it is true
, the element
can be nil
even if that doesn't match any of the other constraints.
It is false
by default, except for optional keys in maps.
Maps are defined using map/2
or directly with a map if no options are
necessary. By default, all keys are required, but optional keys can be defined
with the maybe/1
helper. Non-specified keys are by default forbidden, but it
can be changed by adding the any_key()
key in the map.
map(
%{
# Define two required keys
"id" => integer(),
"name" => string(),
# Define one optional field
maybe("age") => integer(),
# Accept any other key as long as the values are strings
any_key() => string()
},
nil: false
)
# If we drop the `nil` option, we can ignore the `map()` function
%{
"id" => integer(),
"name" => string(),
maybe("age") => integer(),
any_key() => string()
}
Structs work similarly to map but with the structure/2
helper:
structure(
%Profile{id: integer(), username: string(), created_at: datetime()}
)
# Or just
%Profile{id: integer(), username: string(), created_at: datetime()}
Opaque structs can be checked by specifying only the module:
structure(Profile)
Lists are validated by providing a schema that every item must conform to:
# A list of strings with at least two elements
list(string(), min_length: 2)
# A list of numbers (shortcut)
[number()]
Tuples are validated by providing a schema for each element:
# A two-element tuple with an atom and a string
tuple({atom(), string()})
# A three-element tuple with three floats (shortcut)
{float(), float(), float()}
Literal values (constants) are validated using the literal/2
helper
or the value itself for numbers, atoms and strings:
# Match the list `[1, 2, 3]`
literal([1, 2, 3])
# Match an {:ok, string} tuple (two alternatives)
{literal(:ok), string()}
{:ok, string()}
Elixir has 4 built-in structs for date and time. They can be checked with the
date()
, time()
, datetime()
and naive_datetime()
helpers:
# A DateTime before 1 January 2012 at 12:00:00
datetime(before: ~U[2012-01-01 12:00:00Z])
# A Time after 08:00
time(after: ~T[08:00:00])
Recursive schemas can be defined by providing 0-arity functions:
def tree_schema do
%{
value: number(),
children: list(&tree_schema/0)
}
end
Unions can be defined in two ways:
- Using the
union/1
helper:
union([string(non_empty: true), number(min: 0)])
- Using 1-arity functions that decide which schema to use based on the input data:
%{
# Metadata is either a map with string values, or a list of strings
"metadata" => fn
%{} -> map(%{any_key() => string()})
[_ | _] -> list(string())
end
}
Data can be automatically casted to the expected type if possible. The data is validated after the casting has been performed:
# Accept positive numbers, or strings representing positive numbers
number(cast_from: :string, min: 0)
# With a custom converter
map(%{"value" => number()}, cast_from: {:string, with: &Jason.decode/2})
You replace all the errors on an element by a single error message with
on_error
:
iex> value = "xX-DarkL0rd-Xx"
iex> schema1 = string(format: ~r/^[a-zA-Z_]+$/)
iex> validate(value, schema1)
[
%Flawless.Error{context: [], message: "Value \"xX-DarkL0rd-Xx\" does not match regex ~r/^[a-zA-Z_]+$/."}
]
iex> schema2 = string(format: ~r/^[a-zA-Z_]+$/, on_error: "The username should only contain letters or underscores.")
iex> validate(value, schema2)
[
%Flawless.Error{context: [], message: "The username should only contain letters or underscores."}
]
You can validate data against a schema with the validate/3
function. It
returns a list of errors, which is empty if the data is valid.
iex> schema = %{name: string(), age: number()}
iex> validate(%{name: :Colin}, schema)
[
%Flawless.Error{context: [], message: "Missing required fields: :age (number)."},
%Flawless.Error{context: [:name], message: "Expected type: string, got: :Colin."}
]
iex> validate(%{name: "Colin", age: 26}, schema)
[]
You can validate that a schema you're using is a valid schema with the
validate_schema/1
function:
schema = %{
name: string(),
age: number()
}
validate_schema(schema)
It returns the same kind of errors as validate/3
. The "schema of a schema" is
actually defined using this library, and that schema validates itself.
By default, validating a value against a schema will validate the schema first.
If you wish to disable that behaviour (in particular if you can the function
many times with the same schema), set the check_schema
option to false.
If you find a bug, or if you would like to propose improvements, please open an issue or submit a PR.
If this library does not fit exactly your needs, check out those other validation libraries. One of them might be best suited to your use case or preferences:
The source code of Flawless is licensed under the MIT License.