/flawless

An Elixir lib to validate data

Primary LanguageElixirMIT LicenseMIT

Flawless

Hex.pm hexdocs.pm

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.

Why another validation library?

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.

Schema definition

This section is only an overview. For details, see the dedicated Schema definition page.

Helpers

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:

The any() helper is the only helper without a specific type. It can be used to match literally anything.

Checks

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.")
)

Primitive types

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)

Nullable values

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

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

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

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

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()}

Literals

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()}

Date & Time

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

Recursive schemas can be defined by providing 0-arity functions:

def tree_schema do
  %{
    value: number(),
    children: list(&tree_schema/0)
  }
end

Unions

Unions can be defined in two ways:

  1. Using the union/1 helper:
union([string(non_empty: true), number(min: 0)])
  1. 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
}

Casting

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})

Overriding error messages

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."}
]

Validate data

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)
[]

Validate schemas

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.

Not what you need?

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:

License

The source code of Flawless is licensed under the MIT License.