Validate data against specifications
What/why
You want to validate input data before it makes its way into the rest of your system.
You are sending data to a remote client and want to make sure you always send valid data.
You want to make sure that data obeys a spec.
Features
- Anything can be a spec: anything that implements the
AsSpecprotocol and returns truthy/falsy values can be a spec. Default implementations provided forfn/1,{Module, :function},MapSet(if a value is a member of a set),Range(if a value is in a range). See the tests for more. - Compose specs arbitrarily with
all?(all specs must pass in order to pass the parent spec) andany?(any passing spec passes the parent spec) - Validate maps for required and optional keys
- Robust failure reporting
Examples
Validate data
valid? returns true or false.
# simple spec = fn v -> Integer.is_even(v) end Spec.valid?(spec, 2) == true Spec.valid?(spec, 3) == false # all spec = Spec.all?([ fn v -> Integer.is_even(v) end, fn v -> v < 10 end ]) Spec.valid?(spec, 2) == true Spec.valid?(spec, 12) == false # note the keyword list spec = Spec.any?( even: fn v -> Integer.is_even(v) end, less_than_10: fn v -> v < 10 end ) Spec.valid?(spec, 2) == true # is_even Spec.valid?(spec, 9) == true # < 10 Spec.valid?(spec, 13) == false # neither # maps Spec.keys( # required keys must be present, and must pass the specs required: [ a: Spec.all?([&is_binary/1, fn v -> String.length(v) < 5 end]), b: &is_integer/1 ], # optional keys can be missing, but if they are present, # they must pass the specs optional: [c: &is_atom/1] ) Spec.valid?(spec, %{a: "hi"}) == false # :b missing Spec.valid?(spec, %{a: "hi", b: 8}) == true Spec.valid?(spec, %{a: "hello there", b: 8}) == false # String.length("hello there") > 5 Spec.valid?(spec, %{a: "hi", b: 8, c: :hi}) == true Spec.valid?(spec, %{a: "hi", b: 8, c: %{}}) == false # :c not an atom
Conform data
conform returns {:ok, data} or {:error, explanation}.
Explanation contains the failing value, the failing spec, and the path to reach the failing value/spec.
Spec.conform(fn v -> Integer.is_even(v) end, 2) == {:ok, 2} # map with error reporting spec = Spec.keys( required: [ a: Spec.all?([&is_binary/1, fn v -> String.length(v) < 5 end]), b: &is_integer/1 ], optional: [] ) assert { :error, [ %{value: "not an int", path: [:b], spec: "&:erlang.is_integer/1"}, %{value: 842_848_024, path: [:a], spec: "&:erlang.is_binary/1"} ] } = Spec.conform(spec, %{a: 842_848_024, b: "not an int"})
Complex, "real" example
defmodule CarSpecs do require Spec def financing(_v) do Spec.keys( required: [ interest_rate: {CarSpecs, :financing_interest_rate}, term_months: {CarSpecs, :financing_term_months} ], optional: [ contingent: {CarSpecs, :financing_contingent} ] ) end def financing_interest_rate(_v) do Spec.all?([&is_float/1, fn v -> v > 0.0 end]) end def financing_term_months(_v) do Spec.any?(short: 24..48, longer: 72..120) end def financing_contingent(_v) do Spec.keys( required: [ credit_score: fn score -> score > 700 end ], optional: [] ) end def msrp(_v) do Spec.all?([&is_integer/1, fn v -> v > 0 end]) end def make(v) do Enum.member?(MapSet.new([:lancia, :ferrari]), v) end def model(v) do is_binary(v) end def miles(v) do v >= 0 end def year(_v) do Spec.all?([&is_binary/1, fn v -> String.length(v) == 4 end]) end end defmodule SpecDataTest do require Spec use ExUnit.Case doctest Spec test "failing" do data = %{ financing: %{ interest_rate: "sof", term_months: 60 }, msrp: 27_001, make: :lancia, model: "delta", miles: 2402, year: 2025 } spec = Spec.keys( required: [ financing: {CarSpecs, :financing}, msrp: {CarSpecs, :msrp}, make: {CarSpecs, :make}, model: {CarSpecs, :model}, miles: {CarSpecs, :miles}, year: {CarSpecs, :year} ], optional: [] ) assert { :error, [ %{value: 2025, path: [:year], spec: _}, %{ path: [ :financing, :term_months, :longer ], spec: "72..120", value: 60 }, %{ path: [ :financing, :term_months, :short ], spec: "24..48", value: 60 }, %{value: "sof", path: [:financing, :interest_rate], spec: _} ] } = Spec.conform(spec, data) end test "ok" do data = %{ financing: %{ interest_rate: 3.9, term_months: 48, contingent: %{ credit_score: 720 } }, msrp: 27_001, make: :lancia, model: "delta", miles: 8, year: "2025" } spec = Spec.keys( required: [ financing: {CarSpecs, :financing}, msrp: {CarSpecs, :msrp}, make: {CarSpecs, :make}, model: {CarSpecs, :model}, miles: {CarSpecs, :miles}, year: {CarSpecs, :year} ], optional: [] ) assert {:ok, _} = Spec.conform(spec, data) end end