├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── lib ├── saul.ex └── saul │ ├── enum.ex │ ├── error.ex │ ├── map.ex │ ├── tuple.ex │ ├── validator.ex │ └── validator │ ├── all_of.ex │ ├── literal.ex │ ├── map.ex │ ├── member.ex │ ├── named_validator.ex │ └── one_of.ex ├── mix.exs ├── mix.lock └── test ├── saul └── error_test.exs ├── saul_test.exs └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | 19 | # Benchmark snapshots 20 | bench/snapshots 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | 3 | matrix: 4 | include: 5 | - otp_release: 18.3 6 | elixir: 1.4.2 7 | - otp_release: 19.2 8 | elixir: 1.4.2 9 | 10 | sudo: 11 | false 12 | 13 | script: 14 | mix test --trace 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Andrea Leopardi 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Saul (Now a Relic of Times Past) 2 | 3 | [![Build Status](https://travis-ci.org/whatyouhide/saul.svg?branch=master)](https://travis-ci.org/whatyouhide/saul) 4 | 5 | > :warning: This library is **not meant for production** and is **not actively developed** (case in point, it's archived). I started it out years ago, but it never quite went anywhere. Still, I think it's a nice example of a functional library written in Elixir, and I think it can serve an educational purpose for people coming to the language. Enjoy! — Andrea, Nov 2022 6 | 7 | Saul is (was) a data validation and conformation library for Elixir. 8 | 9 | ![Cover image](http://i.imgur.com/9DXjXjA.jpg) 10 | 11 | Saul is a data validation and conformation library. It tries to solve the problem of validating the shape and content of some data (most useful when such data come from an external source) and of conforming those data to arbitrary formats. 12 | 13 | The goal of Saul is to provide a declarative and composable way to define data validation/conformation. The basic unit of validation is a **validator** which is either a function or a term that implements the `Saul.Validator` protocol. The return value of validator functions or implementations of `Saul.Validator.validate/2` has to be either `{:ok, transformed}` to signify a successful validation and conformation, `{:error, term}` to signify a failed validation with a given reason, or a boolean to signify just successful/failed validation with no conformation step. These return values have been chosen because of their widespread presence in Elixir and Erlang code: for example, allowing to return booleans means any predicate function (such as `String.valid?/1`) can be used as validator. 14 | 15 | Validators can be a powerful abstraction because they're easy to *combine*: for example, the `Saul.one_of/1` combinator takes a list of validators and returns a validator that passes if one of the given validators pass. Saul provides both "basic" validators as well as validator combinators, as well as a single entry point to validate data (`Saul.validate/2`). See [the documentation][documentation] for detailed information on all the provided features. 16 | 17 | ## Installation 18 | 19 | Add the `:saul` dependency to your `mix.exs` file: 20 | 21 | ```elixir 22 | defp deps() do 23 | [{:saul, "~> 0.1"}] 24 | end 25 | ``` 26 | 27 | If you're not using `:extra_applications` from Elixir 1.4 and above, also add `:saul` to your list of applications: 28 | 29 | ```elixir 30 | defp application() do 31 | [applications: [:logger, :saul]] 32 | end 33 | ``` 34 | 35 | Then, run `mix deps.get` in your shell to fetch the new dependency. 36 | 37 | ## Usage 38 | 39 | Validators are just data structures that can be moved around. You can create arbitrarely complex ones: 40 | 41 | ```elixir 42 | string_to_integer = 43 | fn string -> 44 | case Integer.parse(string) do 45 | {int, ""} -> {:ok, int} 46 | _other -> {:error, "not parsable as integer"} 47 | end 48 | end 49 | |> Saul.named_validator("string_to_integer") 50 | 51 | stringy_integer = Saul.all_of([ 52 | &is_binary/1, 53 | string_to_integer, 54 | ]) 55 | ``` 56 | 57 | Now you can use them to validate data: 58 | 59 | ```elixir 60 | iex> Saul.validate!("123", stringy_integer) 61 | 123 62 | iex> Saul.validate!("nope", stringy_integer) 63 | ** (Saul.Error) (string_to_integer) not parsable as integer - failing term: "nope" 64 | ``` 65 | 66 | ## Contributing 67 | 68 | Clone the repository and run `$ mix test`. To generate docs, run `$ mix docs`. 69 | 70 | ## License 71 | 72 | Saul is released under the ISC license, see the [LICENSE](LICENSE) file. 73 | 74 | ## Attribution 75 | 76 | Many ideas in this library are inspired by [clojure.spec][clojure-spec]. 77 | 78 | A special thanks for the feedback and encouragement goes to the awesome @lexmag, as well as to @josevalim, @ericmj, and @michalmuskala. 79 | 80 | 81 | [documentation]: https://hexdocs.pm/saul 82 | [clojure-spec]: https://clojure.org/about/spec 83 | -------------------------------------------------------------------------------- /lib/saul.ex: -------------------------------------------------------------------------------- 1 | defmodule Saul do 2 | @moduledoc """ 3 | Contains the core of the functionality provided by Saul. 4 | 5 | Saul is a data validation and conformation library. It tries to solve the 6 | problem of validating the shape and content of some data (most useful when 7 | such data come from an external source) and of conforming those data to 8 | arbitrary formats. 9 | 10 | Saul is based on the concept of **validators**: a validator is something that 11 | knows how to validate a term and transform it to something else if 12 | necessary. A good example of a validator could be something that validates 13 | that a term is a string representation of an integer and that converts such 14 | string to the represented integer. 15 | 16 | Validators are a powerful abstraction as they can be easily *combined*: for 17 | example, the `Saul.one_of/1` function takes a list of validators and returns a 18 | validator that passes if one of the given validators pass. Saul provides both 19 | "basic" validators as well as validator combinators. 20 | 21 | ## Validators 22 | 23 | A validator can be: 24 | 25 | * a function that takes one argument 26 | * a term that implements the `Saul.Validator` protocol 27 | 28 | The return value of function validators or implementations of 29 | `Saul.Validator.validate/2` has to be one of the following: 30 | 31 | * `{:ok, transformed}` - it means validation succeeded (the input term is 32 | considered valid) and `transformed` is the conformed value for the input 33 | term. 34 | 35 | * `{:error, reason}` - it means validation failed (the input term is 36 | invalid). `reason` can be any term: if it is not a `Saul.Error` struct, 37 | `validate/2` will take care of wrapping it into a `Saul.Error`. 38 | 39 | * `true` - it means validation succeeded. It is the same as `{:ok, 40 | transformed}`, but it can be used when the transformed value is the same 41 | as the input value. This is useful for "predicate" validators (functions 42 | that take one argument and return a boolean). 43 | 44 | * `false` - it means validation failed. It is the same as `{:error, reason}`, 45 | except the reason only mentions that a "predicate failed". 46 | 47 | Returning a boolean value is supported so that existing predicate functions 48 | can be used as validators without modification. Examples of such functions are 49 | type guards (`is_binary/1` or `is_list/1`), functions like `String.valid?/1`, 50 | and many others. 51 | 52 | ## Validating 53 | 54 | The only entry point for validation is `validate/2`. It hides all the 55 | complexity of the possible return values of validators (described in the 56 | "Validators" section) and always returns `{:ok, transformed}` (where 57 | `transformed` can be the same term as the term being validated) or `{:error, 58 | %Saul.Error{}}`. See the documentation for `validate/2` for more detailed 59 | documentation. 60 | """ 61 | 62 | @typedoc """ 63 | The type defining a validator. 64 | 65 | See the module documentation for more information on what are validators. 66 | """ 67 | @type validator(transformed_type) :: 68 | (term -> {:ok, transformed_type} | {:error, term}) 69 | | (term -> boolean) 70 | | Saul.Validator.t 71 | 72 | @doc """ 73 | Validates the given `term` through the given `validator`. 74 | 75 | If the validator successfully matches `term`, then the return value of this 76 | function is `{:ok, transformed}` where `transformed` is the result of the 77 | transformation applied by the validator. If the validator returns `{:error, 78 | reason}`, the return value of this function is `{:error, %Saul.Error{}}`. 79 | 80 | Note that the given validator can return any type of `reason` when returning 81 | an `:error` tuple: `validate/2` will take care of wrapping it into a 82 | `%Saul.Error{}`. This is done so that users can work with a consistent 83 | interface but at the same time they can use already existing functions as 84 | validators (since `{:ok, term} | {:error, term}` is quite a common API in 85 | Erlang/Elixir). 86 | 87 | ## Examples 88 | 89 | iex> to_string = &{:ok, to_string(&1)} 90 | iex> Saul.validate(:foo, to_string) 91 | {:ok, "foo"} 92 | iex> Saul.validate("hello", to_string) 93 | {:ok, "hello"} 94 | 95 | iex> failer = fn(_) -> {:error, :bad} end 96 | iex> {:error, %Saul.Error{} = error} = Saul.validate(3.14, failer) 97 | iex> error.reason 98 | ":bad" 99 | 100 | """ 101 | @spec validate(term, validator(value)) :: 102 | {:ok, value} | {:error, Saul.Error.t} | no_return when value: term 103 | def validate(term, validator) do 104 | result = 105 | case validator do 106 | fun when is_function(fun, 1) -> validator.(term) 107 | _ -> Saul.Validator.validate(validator, term) 108 | end 109 | 110 | case result do 111 | {:ok, _transformed} = result -> 112 | result 113 | true -> 114 | {:ok, term} 115 | {:error, %Saul.Error{}} = result -> 116 | result 117 | {:error, reason} -> 118 | {:error, %Saul.Error{validator: validator, reason: inspect(reason), term: {:term, term}}} 119 | false -> 120 | {:error, %Saul.Error{validator: validator, reason: "predicate failed", term: {:term, term}}} 121 | other -> 122 | raise ArgumentError, "validator should return {:ok, term}, {:error, term}, " <> 123 | "or a boolean, got: #{inspect(other)}" 124 | end 125 | end 126 | 127 | @doc """ 128 | Validates the given `term` through the given `validator`, raising in case of errors. 129 | 130 | This function works like `validate/2`, but it returns the transformed term 131 | directly in case validation succeeds or raises a `Saul.Error` exception in 132 | case validation fails. 133 | 134 | ## Examples 135 | 136 | iex> Saul.validate!("foo", &is_binary/1) 137 | "foo" 138 | iex> Saul.validate!("foo", &is_atom/1) 139 | ** (Saul.Error) (&:erlang.is_atom/1) predicate failed - failing term: "foo" 140 | 141 | """ 142 | @spec validate!(term, validator(value)) :: value | no_return when value: any 143 | def validate!(term, validator) do 144 | case validate(term, validator) do 145 | {:ok, transformed} -> 146 | transformed 147 | {:error, %Saul.Error{} = error} -> 148 | raise(error) 149 | end 150 | end 151 | 152 | ## Validators 153 | 154 | @doc """ 155 | Returns a validator that performs the same validation as `validator` but has 156 | the name `name`. 157 | 158 | This function is useful in order to have better errors when validation 159 | fails. In such cases, the name of each of the failing validators is printed 160 | alongside the error. If your validator is an anonymous function `f`, such name 161 | will be `inspect(f)`, so it won't be very useful when trying to understand 162 | errors. Naming a validator is also useful when your validator is an isolated 163 | logical unit (such as a validator that validates that a term is an integer, 164 | positive, and converts it to its Roman representation). 165 | 166 | ## Examples 167 | 168 | iex> failer = Saul.named_validator(fn(_) -> {:error, :oops} end, "validator that always fails") 169 | iex> Saul.validate!(:foo, failer) 170 | ** (Saul.Error) (validator that always fails) :oops - failing term: :foo 171 | 172 | """ 173 | @spec named_validator(validator(value), String.t) :: validator(value) when value: any 174 | def named_validator(validator, name) do 175 | %Saul.Validator.NamedValidator{name: name, validator: validator} 176 | end 177 | 178 | @doc """ 179 | Returns a validator that always passes and applies the given transformation 180 | `fun`. 181 | 182 | This function is useful when a validator is only applying a transformation, 183 | and not performing any validation. Using this function is only beneficial 184 | inside more complex validators, such as `all_of/1`, where `fun` needs to have 185 | the shape of a validator. For other cases, you can just apply `fun` directly 186 | to the input term. 187 | 188 | ## Examples 189 | 190 | For example, if you validated that a term is a binary in some way, but want to 191 | transform it to a charlist during validation, you could wrap 192 | `String.to_charlist/1` inside `transform/1`: 193 | 194 | iex> term = "this is a string" 195 | iex> Saul.validate!(term, Saul.transform(&String.to_charlist/1)) 196 | 'this is a string' 197 | 198 | """ 199 | @spec transform((input -> output)) :: (input -> {:ok, output}) when input: var, output: var 200 | def transform(fun) when is_function(fun, 1) do 201 | &{:ok, fun.(&1)} 202 | end 203 | 204 | @doc """ 205 | Returns a validator that checks that the input term is equal to `term`. 206 | 207 | This is a basic validator that allows to check for literal terms (hence its 208 | name, "lit"). If the input term is equal to `term`, then it is returned 209 | unchanged. 210 | 211 | ## Examples 212 | 213 | iex> three = Saul.lit(3) 214 | iex> Saul.validate(3, three) 215 | {:ok, 3} 216 | iex> {:error, error} = Saul.validate(4, three) 217 | iex> error.reason 218 | "expected exact term 3" 219 | 220 | """ 221 | @spec lit(value) :: validator(value) when value: term 222 | def lit(term) do 223 | %Saul.Validator.Literal{term: term} 224 | end 225 | 226 | @doc """ 227 | Returns a validator that matches when all the given `validators` match. 228 | 229 | `validators` has to be a *non-empty* list of validators. 230 | 231 | The validation stops and fails as soon as one of the `validators` fails, or 232 | succeeds and returns the value returned by the last validator if all 233 | validators succeed. When a validator succeeds, the transformed value it 234 | returns is passed as the input to the next validator in the list: this allows 235 | to simulate a "pipeline" of transformations that halts as soon as something 236 | doesn't match (similar to a small subset of what you could achieve with the 237 | `with` Elixir special form). 238 | 239 | ## Examples 240 | 241 | iex> validator = Saul.all_of([&{:ok, to_string(&1)}, &is_binary/1]) 242 | iex> Saul.validate(:hello, validator) 243 | {:ok, "hello"} 244 | 245 | iex> validator = Saul.all_of([&is_binary/1, &{:ok, &1}]) 246 | iex> Saul.validate!(:hello, validator) 247 | ** (Saul.Error) (&:erlang.is_binary/1) predicate failed - failing term: :hello 248 | 249 | """ 250 | @spec all_of(nonempty_list(validator(term))) :: validator(term) 251 | def all_of([_ | _] = validators) do 252 | %Saul.Validator.AllOf{validators: validators} 253 | end 254 | 255 | @doc """ 256 | 257 | Returns a validator that matches if one of the given `validators` match. 258 | 259 | `validators` has to be a *non-empty* list of validators. 260 | 261 | The validation stops and succeeds as soon as one of the `validators` 262 | succeeds. The value returned by the succeeding validator is the value returned 263 | by this validator as well. If all validators fail, an error that shows all the 264 | failures is returned. 265 | 266 | ## Examples 267 | 268 | iex> validator = Saul.one_of([&is_binary/1, &is_atom/1]) 269 | iex> Saul.validate(:foo, validator) 270 | {:ok, :foo} 271 | 272 | """ 273 | @spec one_of(nonempty_list(validator(term))) :: validator(term) 274 | def one_of([_ | _] = validators) do 275 | %Saul.Validator.OneOf{validators: validators} 276 | end 277 | 278 | @doc """ 279 | Returns a validator that matches an enumerable where all elements match 280 | `validator`. 281 | 282 | The return value of this validator is a value constructed by collecting the 283 | values in the given enumerable transformed according to `validator` into the 284 | collectable specified by the `:into` option. This validator can be considered 285 | analogous to the `for` special form (with the `:into` option as well), but 286 | with error handling. If any of the elements in the given enumerable fails 287 | `validator`, this validator fails. 288 | 289 | ## Options 290 | 291 | * `:into` - (`t:Collectable.t/0`) the collectable where the transformed values 292 | should end up in. Defaults to `[]`. 293 | 294 | ## Examples 295 | 296 | iex> validator = Saul.enum_of(&{:ok, {inspect(&1), &1}}, into: %{}) 297 | iex> Saul.validate(%{foo: :bar}, validator) 298 | {:ok, %{"{:foo, :bar}" => {:foo, :bar}}} 299 | iex> Saul.validate([1, 2, 3], validator) 300 | {:ok, %{"1" => 1, "2" => 2, "3" => 3}} 301 | 302 | """ 303 | @spec enum_of(Saul.validator(term), Keyword.t) :: Saul.validator(Collectable.t) 304 | def enum_of(validator, options \\ []) when is_list(options) do 305 | Saul.Enum.enum_of(validator, options) 306 | end 307 | 308 | @doc """ 309 | Returns a validator that validates a map with the shape specified by 310 | `validators_map`. 311 | 312 | `validators_map` must be a map with values as keys and two-element tuples 313 | `{required_or_optional, validator}` as values. The input map will be validated 314 | like this: 315 | 316 | * each key is checked against the validator at the corresponding key in 317 | `validators_map` 318 | 319 | * `{:required, validator}` validators mean that their corresponding key is 320 | required in the map; if it's not present in the input map, this 321 | validator fails 322 | 323 | * `{:optional, validator}` validators mean that their corresponding key 324 | can be not present in the map, and it's only validated with `validator` 325 | in case it's present 326 | 327 | The map returned by this validator has unchanged keys and values that are the 328 | result of the validator for each key. 329 | 330 | ## Options 331 | 332 | * `:strict` (boolean) - if this option is `true`, then this validator fails 333 | if the input map has keys that are not in `validators_map`. Defaults to 334 | `false`. 335 | 336 | ## Examples 337 | 338 | iex> validator = Saul.map([strict: false], %{ 339 | ...> to_string: {:required, &{:ok, to_string(&1)}}, 340 | ...> is_atom: {:optional, &is_atom/1}, 341 | ...> }) 342 | iex> Saul.validate(%{to_string: :foo, is_atom: :bar}, validator) 343 | {:ok, %{to_string: "foo", is_atom: :bar}} 344 | iex> Saul.validate(%{to_string: :foo}, validator) 345 | {:ok, %{to_string: "foo"}} 346 | 347 | """ 348 | @spec map(Keyword.t , %{optional(term) => {:required | :optional, validator(term)}}) :: 349 | validator(map) 350 | def map(options \\ [], validators_map) when is_list(options) and is_map(validators_map) do 351 | Saul.Validator.Map.new(validators_map, options) 352 | end 353 | 354 | @doc """ 355 | Returns a validator that validates a tuples with elements that match the 356 | validator at their corresponding position in `validators`. 357 | 358 | The return value of this validator is a tuple with the same number of elements 359 | as `validators` (and the input tuple) where elements are the result of the 360 | validator in their corresponding position in `validators`. 361 | 362 | ## Examples 363 | 364 | iex> atom_to_string = Saul.transform(&Atom.to_string/1) 365 | iex> Saul.validate({:foo, :bar}, Saul.tuple({atom_to_string, atom_to_string})) 366 | {:ok, {"foo", "bar"}} 367 | 368 | """ 369 | @spec tuple(tuple) :: validator(tuple) 370 | def tuple(validators) when is_tuple(validators) do 371 | Saul.Tuple.tuple(validators) 372 | end 373 | 374 | @doc """ 375 | Returns a validator that validates a map with keys that match `key_validator` 376 | and values that match `value_validator`. 377 | 378 | The return value of this validator is a map where keys are the result of 379 | `key_validator` for each key and values are the result of `value_validator` 380 | for the corresponding key. If any key or value fail, this validator fails. 381 | 382 | Note that if `key_validator` ends up transforming two keys into the same term, 383 | then they will collapse under just one key-value pair in the transformed map 384 | and there is no guarantee on which value will prevail. 385 | 386 | ## Examples 387 | 388 | iex> integer_to_string = Saul.all_of([&is_integer/1, &{:ok, Integer.to_string(&1)}]) 389 | iex> validator = Saul.map_of(integer_to_string, &is_atom/1) 390 | iex> Saul.validate(%{1 => :ok, 2 => :not_so_ok}, validator) 391 | {:ok, %{"1" => :ok, "2" => :not_so_ok}} 392 | 393 | """ 394 | @spec map_of(validator(key), validator(value)) :: validator(%{optional(key) => value}) 395 | when key: any, value: any 396 | def map_of(key_validator, map_validator) do 397 | Saul.Map.map_of(key_validator, map_validator) 398 | end 399 | 400 | @doc """ 401 | Returns a validator that validates a list where all elements match `validator`. 402 | 403 | The return value of this validator is a list where each element is the return 404 | value of `validator` for the corresponding element in the input 405 | list. Basically this is analogous to `Enum.map/2` but with error handling. If 406 | any of the elements in the list fail `validator`, this validator fails. 407 | 408 | ## Examples 409 | 410 | iex> integer_to_string = Saul.all_of([&is_integer/1, &{:ok, Integer.to_string(&1)}]) 411 | iex> Saul.validate([1, 2, 3], Saul.list_of(integer_to_string)) 412 | {:ok, ["1", "2", "3"]} 413 | 414 | """ 415 | @spec list_of(validator(value)) :: validator([value]) when value: any 416 | def list_of(validator) do 417 | [&is_list/1, enum_of(validator, into: [])] 418 | |> all_of() 419 | |> named_validator("list_of") 420 | end 421 | 422 | @doc """ 423 | Returns a validator that checks if the input term is a member of `enumerable`. 424 | 425 | The return value of this validator is the input term, unmodified. 426 | `Enum.member?/2` is used to check if the input term is a member of 427 | `enumerable`. 428 | 429 | ## Examples 430 | 431 | iex> Saul.validate(:bar, Saul.member([:foo, :bar, :baz])) 432 | {:ok, :bar} 433 | iex> Saul.validate(50, Saul.member(1..100)) 434 | {:ok, 50} 435 | 436 | """ 437 | @spec member(Enumerable.t) :: validator(term) 438 | def member(enumerable) do 439 | %Saul.Validator.Member{enumerable: enumerable} 440 | end 441 | end 442 | -------------------------------------------------------------------------------- /lib/saul/enum.ex: -------------------------------------------------------------------------------- 1 | defmodule Saul.Enum do 2 | @moduledoc false 3 | 4 | alias Saul.Error 5 | 6 | @spec enum_of(Saul.validator(term), Keyword.t) :: Saul.validator(Collectable.t) 7 | def enum_of(validator, options) do 8 | &validate_enum_of(&1, validator, options) 9 | end 10 | 11 | defp validate_enum_of(enum, validator, options) do 12 | {acc, collectable_cont} = 13 | options 14 | |> Keyword.get(:into, []) 15 | |> Collectable.into() 16 | 17 | try do 18 | Enum.reduce(enum, {acc, 0}, fn item, {acc, index} -> 19 | case Saul.validate(item, validator) do 20 | {:ok, transformed} -> 21 | {collectable_cont.(acc, {:cont, transformed}), index + 1} 22 | {:error, %Error{} = error} -> 23 | throw(%Error{position: "at position #{index}", reason: error}) 24 | end 25 | end) 26 | catch 27 | %Error{} = error -> 28 | {:error, %{error | validator: "enum_of"}} 29 | else 30 | {transformed, _index} -> 31 | {:ok, collectable_cont.(transformed, :done)} 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/saul/error.ex: -------------------------------------------------------------------------------- 1 | defmodule Saul.Error do 2 | @moduledoc """ 3 | A struct representing a validation error. 4 | 5 | When validation fails, `Saul.validate/2` always returns a `Saul.Error` struct 6 | (even if the used validator doesn't return such a struct, see the 7 | documentation for `Saul` for more information). This struct is a valid Elixir 8 | exception. 9 | 10 | A returned `Saul.Error` struct is usually not inspected directly, as it is a 11 | nested data structure that contains a tree of the errors returned by all the 12 | nested validations of the validator passed to `Saul.validate/2`. 13 | 14 | `Saul.Error` structs are mostly meant to be used to generate comprehensible 15 | string messages. To do that, since `Saul.Error` is an Elixir exception, use 16 | `Exception.message/1`. 17 | 18 | For example, a common use case is logging the error when some incoming 19 | parameters fail validation: 20 | 21 | case Saul.validate(params, my_params_validator) do 22 | {:ok, validated} -> 23 | # the life of my application goes on here 24 | {:error, %Saul.Error{} = error} -> 25 | Logger.error(Exception.message(error)) 26 | end 27 | 28 | Note that since it is an Elixir exception, a `Saul.Error` struct can easily be 29 | raised (for example with `Kernel.raise/1`). 30 | """ 31 | 32 | defexception [:validator, :position, :reason, :term] 33 | 34 | def message(%__MODULE__{} = error) do 35 | %{validator: validator, position: position, reason: reason, term: term} = error 36 | 37 | reason = 38 | case reason do 39 | %__MODULE__{} -> 40 | message(reason) 41 | reason when is_binary(reason) -> 42 | reason 43 | end 44 | 45 | IO.iodata_to_binary([ 46 | if(validator, do: ["(", validator_to_string(validator), ") "], else: []), 47 | if(position, do: [position, " -> "], else: []), 48 | reason, 49 | case(term, do: ({:term, term} -> [" - failing term: ", inspect(term)]; _ -> [])) 50 | ]) 51 | end 52 | 53 | defp validator_to_string(validator) when is_binary(validator), do: validator 54 | defp validator_to_string(validator), do: inspect(validator) 55 | end 56 | -------------------------------------------------------------------------------- /lib/saul/map.ex: -------------------------------------------------------------------------------- 1 | defmodule Saul.Map do 2 | @moduledoc false 3 | 4 | alias Saul.Error 5 | 6 | @spec map_of(Saul.validator(key), Saul.validator(value)) :: 7 | Saul.validator(%{optional(key) => value}) when key: any, value: any 8 | def map_of(key_validator, value_validator) do 9 | map_validator = Saul.enum_of(&pair_validator(&1, key_validator, value_validator), into: %{}) 10 | 11 | fn term -> 12 | with {:ok, _map} <- Saul.validate(term, &is_map/1), 13 | {:error, %Error{reason: reason}} <- Saul.validate(term, map_validator) do 14 | {:error, reason} 15 | end 16 | end 17 | end 18 | 19 | defp pair_validator({key, value}, key_validator, value_validator) do 20 | case Saul.validate(key, key_validator) do 21 | {:ok, transformed_key} -> 22 | case Saul.validate(value, value_validator) do 23 | {:ok, transformed_value} -> 24 | {:ok, {transformed_key, transformed_value}} 25 | {:error, %Error{} = error} -> 26 | {:error, %Error{position: "at key #{inspect(key)}", reason: error}} 27 | end 28 | {:error, %Error{} = error} -> 29 | {:error, %Error{reason: "invalid key: #{Exception.message(error)}", term: {:term, key}}} 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/saul/tuple.ex: -------------------------------------------------------------------------------- 1 | defmodule Saul.Tuple do 2 | @moduledoc false 3 | 4 | alias Saul.Error 5 | 6 | def tuple(validators) when is_tuple(validators) do 7 | Saul.all_of([&is_tuple/1, &validate_tuple(&1, validators)]) 8 | end 9 | 10 | defp validate_tuple(tuple, validators) when tuple_size(tuple) == tuple_size(validators) do 11 | list_tuple = Tuple.to_list(tuple) 12 | list_validators = Tuple.to_list(validators) 13 | 14 | validate_pairs(list_tuple, list_validators, []) 15 | end 16 | 17 | defp validate_tuple(tuple, validators) do 18 | reason = 19 | "expected tuple with #{tuple_size(validators)} elements, " <> 20 | "got one with #{tuple_size(tuple)} elements" 21 | {:error, %Error{validator: "tuple", reason: reason}} 22 | end 23 | 24 | defp validate_pairs([elem | rest], [validator | validators], acc) do 25 | case Saul.validate(elem, validator) do 26 | {:ok, transformed} -> 27 | validate_pairs(rest, validators, [transformed | acc]) 28 | {:error, error} -> 29 | {:error, %Error{validator: "tuple", position: "at position #{length(acc)}", reason: error}} 30 | end 31 | end 32 | 33 | defp validate_pairs([], [], acc) do 34 | {:ok, (acc |> Enum.reverse() |> List.to_tuple())} 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/saul/validator.ex: -------------------------------------------------------------------------------- 1 | defprotocol Saul.Validator do 2 | @moduledoc """ 3 | A protocol for transforming terms into validators. 4 | 5 | This protocol allows to transform the terms that implement it into validators. 6 | 7 | For example, say we have a `DateRange` struct in our application already 8 | defined like this: 9 | 10 | defmodule DateRange do 11 | defstruct [:start, :end] 12 | 13 | def in_range?(date, date_range) do 14 | after?(date, date_range.start) and before?(date, date_range.end) 15 | end 16 | end 17 | 18 | We could turn this structure into a validator by implementing the 19 | `Saul.Validator` protocol for it. We could say that a `DateRange` validator 20 | accepts its input if it is a date in the given range, and fails otherwise. The 21 | implementation of this could look like the following: 22 | 23 | defimpl Saul.Validator, for: DateRange do 24 | def validate(date_range, term) do 25 | if DateRange.in_range?(term, date_range) do 26 | {:ok, date_range} 27 | else 28 | {:error, "date not in range"} 29 | end 30 | end 31 | end 32 | 33 | Note that here we used the `{:ok, _} | {:error, _}` return type for 34 | `validate/2` in order to give errors a nice error message: we could have 35 | implemented this validator as just `DateRange.in_range(term, date_range)`, but 36 | then the error would have said only something like "predicate failed". 37 | """ 38 | 39 | @doc """ 40 | Validates the given `term` according to `validator`. 41 | 42 | See the module documentation for `Saul` and the documentation for 43 | `Saul.validate/2` for more information on the possible return values. 44 | """ 45 | @spec validate(term, term) :: {:ok, term} | {:error, term} | boolean 46 | def validate(validator, term) 47 | end 48 | -------------------------------------------------------------------------------- /lib/saul/validator/all_of.ex: -------------------------------------------------------------------------------- 1 | defmodule Saul.Validator.AllOf do 2 | @moduledoc false 3 | 4 | defstruct [:validators] 5 | 6 | defimpl Saul.Validator do 7 | def validate(%{validators: validators}, term) do 8 | do_validate(validators, term) 9 | end 10 | 11 | defp do_validate([validator], term) do 12 | with {:ok, _transformed} = ok_result <- Saul.validate(term, validator), 13 | do: ok_result 14 | end 15 | 16 | defp do_validate([validator | rest], term) do 17 | with {:ok, transformed} <- Saul.validate(term, validator), 18 | do: do_validate(rest, transformed) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/saul/validator/literal.ex: -------------------------------------------------------------------------------- 1 | defmodule Saul.Validator.Literal do 2 | @moduledoc false 3 | 4 | defstruct [:term] 5 | 6 | defimpl Saul.Validator do 7 | def validate(%{term: term}, term) do 8 | {:ok, term} 9 | end 10 | 11 | def validate(%{term: expected}, actual) do 12 | reason = "expected exact term #{inspect(expected)}" 13 | {:error, %Saul.Error{reason: reason, term: {:term, actual}}} 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/saul/validator/map.ex: -------------------------------------------------------------------------------- 1 | defmodule Saul.Validator.Map do 2 | @moduledoc false 3 | 4 | alias Saul.Error 5 | 6 | defstruct [:keys, :required, :optional, :strict?] 7 | 8 | @spec new(%{optional(term) => {:required | :optional, Saul.validator(term)}}, Keyword.t) :: 9 | %__MODULE__{} 10 | def new(validators_map, options) when is_map(validators_map) and is_list(options) do 11 | {required, optional} = 12 | Enum.reduce(validators_map, {MapSet.new(), MapSet.new()}, fn 13 | {key, {:required, _validator}}, {required, optional} -> 14 | {MapSet.put(required, key), optional} 15 | {key, {:optional, _validator}}, {required, optional} -> 16 | {required, MapSet.put(optional, key)} 17 | end) 18 | 19 | %__MODULE__{ 20 | keys: validators_map, 21 | required: required, 22 | optional: optional, 23 | strict?: Keyword.get(options, :strict, false), 24 | } 25 | end 26 | 27 | defimpl Saul.Validator do 28 | alias Saul.Error 29 | 30 | def validate(%Saul.Validator.Map{} = validator, term) do 31 | %{keys: keys, required: required, optional: optional, strict?: strict?} = validator 32 | 33 | with {:ok, map} <- Saul.validate(term, &is_map/1), 34 | map_keys = map |> Map.keys() |> MapSet.new(), 35 | :ok <- validate_presence(map_keys, required, optional, strict?) do 36 | validate_keys(map, keys) 37 | else 38 | {:error, %Error{} = error} -> 39 | {:error, %{error | validator: "map"}} 40 | end 41 | end 42 | 43 | defp validate_presence(map_keys, required, optional, strict?) do 44 | with :ok <- validate_required(map_keys, required), 45 | :ok <- if(strict?, do: validate_strictness(map_keys, required, optional), else: :ok), 46 | do: :ok 47 | end 48 | 49 | defp validate_required(map_keys, required) do 50 | missing_required = MapSet.difference(required, map_keys) 51 | 52 | if MapSet.size(missing_required) > 0 do 53 | reason = "missing required keys: #{inspect(MapSet.to_list(missing_required))}" 54 | {:error, %Error{reason: reason}} 55 | else 56 | :ok 57 | end 58 | end 59 | 60 | defp validate_strictness(map_keys, required, optional) do 61 | allowed = MapSet.union(required, optional) 62 | extra_keys = MapSet.difference(map_keys, allowed) 63 | 64 | if MapSet.size(extra_keys) > 0 do 65 | reason = "unknown keys in strict mode: #{inspect(MapSet.to_list(extra_keys))}" 66 | {:error, %Error{reason: reason}} 67 | else 68 | :ok 69 | end 70 | end 71 | 72 | defp validate_keys(map, key_validators) do 73 | Enum.reduce_while(map, {:ok, _transformed = %{}}, fn {key, value}, {:ok, acc} -> 74 | {_, validator} = Map.get(key_validators, key, {:optional, &{:ok, &1}}) 75 | 76 | case Saul.validate(value, validator) do 77 | {:ok, transformed} -> 78 | {:cont, {:ok, Map.put(acc, key, transformed)}} 79 | {:error, %Error{} = error} -> 80 | {:halt, {:error, %Error{position: "at key #{inspect(key)}", reason: error}}} 81 | end 82 | end) 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/saul/validator/member.ex: -------------------------------------------------------------------------------- 1 | defmodule Saul.Validator.Member do 2 | @moduledoc false 3 | 4 | defstruct [:enumerable] 5 | 6 | defimpl Saul.Validator do 7 | def validate(%{enumerable: enumerable}, term) do 8 | if Enum.member?(enumerable, term) do 9 | {:ok, term} 10 | else 11 | reason = "not a member of #{inspect(enumerable)}" 12 | {:error, %Saul.Error{validator: "member", reason: reason, term: {:term, term}}} 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/saul/validator/named_validator.ex: -------------------------------------------------------------------------------- 1 | defmodule Saul.Validator.NamedValidator do 2 | @moduledoc false 3 | 4 | defstruct [:validator, :name] 5 | 6 | defimpl Saul.Validator do 7 | def validate(%{validator: validator, name: name}, term) do 8 | with {:error, error} <- Saul.validate(term, validator), 9 | do: {:error, %Saul.Error{error | validator: name}} 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/saul/validator/one_of.ex: -------------------------------------------------------------------------------- 1 | defmodule Saul.Validator.OneOf do 2 | @moduledoc false 3 | 4 | defstruct [:validators] 5 | 6 | defimpl Saul.Validator do 7 | def validate(%{validators: validators}, term) do 8 | do_validate(validators, term, _errors = []) 9 | end 10 | 11 | defp do_validate([validator | rest], term, errors) do 12 | with {:error, error} <- Saul.validate(term, validator), 13 | do: do_validate(rest, term, [error | errors]) 14 | end 15 | 16 | defp do_validate([], _term, errors) do 17 | errors = 18 | errors 19 | |> Enum.map(&Exception.message/1) 20 | |> Enum.intersperse(", ") 21 | reason = IO.iodata_to_binary(["all validators failed: [", errors, "]"]) 22 | {:error, %Saul.Error{validator: "one_of", reason: reason}} 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Saul.Mixfile do 2 | use Mix.Project 3 | 4 | @description "Data validation and conformation library for Elixir." 5 | 6 | @repo_url "https://github.com/whatyouhide/saul" 7 | 8 | @version "0.1.0" 9 | 10 | def project() do 11 | [ 12 | app: :saul, 13 | version: @version, 14 | elixir: "~> 1.4", 15 | build_embedded: Mix.env == :prod, 16 | start_permanent: Mix.env == :prod, 17 | deps: deps(), 18 | 19 | # Hex 20 | package: package(), 21 | description: @description, 22 | 23 | # Docs 24 | name: "Saul", 25 | docs: [ 26 | main: "Saul", 27 | source_ref: "v#{@version}", 28 | source_url: @repo_url, 29 | ], 30 | ] 31 | end 32 | 33 | def application() do 34 | [extra_applications: []] 35 | end 36 | 37 | defp package() do 38 | [ 39 | maintainers: ["Andrea Leopardi"], 40 | licenses: ["ISC"], 41 | links: %{"GitHub" => @repo_url}, 42 | ] 43 | end 44 | 45 | defp deps() do 46 | [{:ex_doc, "~> 0.15", only: :dev}] 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"earmark": {:hex, :earmark, "1.1.1", "433136b7f2e99cde88b745b3a0cfc3fbc81fe58b918a09b40fce7f00db4d8187", [:mix], []}, 2 | "ex_doc": {:hex, :ex_doc, "0.15.0", "e73333785eef3488cf9144a6e847d3d647e67d02bd6fdac500687854dd5c599f", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, optional: false]}]}} 3 | -------------------------------------------------------------------------------- /test/saul/error_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Saul.ErrorTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Saul.Error 5 | 6 | test "Exception.message/1" do 7 | import Exception, only: [message: 1] 8 | 9 | error = %Error{validator: "map", position: nil, reason: "invalid keys: :a, :b"} 10 | assert message(error) == "(map) invalid keys: :a, :b" 11 | 12 | error = %Error{ 13 | validator: "map", 14 | position: "at key :foo", 15 | reason: %Error{ 16 | validator: "map", 17 | position: nil, 18 | reason: "invalid keys: :a, :b", 19 | }, 20 | } 21 | assert message(error) == "(map) at key :foo -> (map) invalid keys: :a, :b" 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/saul_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SaulTest do 2 | use ExUnit.Case 3 | 4 | doctest Saul 5 | 6 | alias Saul.Error 7 | 8 | def to_string_with_suffix(term, suffix) do 9 | {:ok, to_string(term) <> suffix} 10 | end 11 | 12 | describe "validate/2" do 13 | import Saul, only: [validate: 2] 14 | 15 | test "accepts 1-arity functions as validators" do 16 | assert validate(12, fn(term) -> {:ok, term} end) == {:ok, 12} 17 | end 18 | 19 | test "accepts 1-arity predicates as validators" do 20 | assert validate(12, &is_integer/1) == {:ok, 12} 21 | assert {:error, %Error{}} = validate(:foo, &is_binary/1) 22 | end 23 | 24 | test "fails when a validator doesn't return one of the allowed values" do 25 | message = "validator should return {:ok, term}, {:error, term}, or a boolean, got: :bad_return" 26 | assert_raise ArgumentError, message, fn -> 27 | Saul.validate(:something, fn(_term) -> :bad_return end) 28 | end 29 | end 30 | end 31 | 32 | describe "validate!/2" do 33 | import Saul, only: [validate: 2, validate!: 2] 34 | 35 | test "returns the transformed value directly if a validator succeeds" do 36 | assert validate!(:foo, &{:ok, &1}) == :foo 37 | end 38 | 39 | test "raises a Saul.Error when a validator fails" do 40 | assert_raise Error, "(val) oops", fn -> 41 | validate!(:foo, fn(_term) -> {:error, %Error{reason: "oops", validator: "val"}} end) 42 | end 43 | end 44 | end 45 | 46 | describe "transform/1" do 47 | import Saul, only: [validate: 2, transform: 1] 48 | 49 | test "wraps a function as a validator" do 50 | assert validate("123", transform(&String.to_integer/1)) == {:ok, 123} 51 | assert_raise ArgumentError, fn -> 52 | validate("not an integer", transform(&String.to_integer/1)) 53 | end 54 | end 55 | end 56 | 57 | describe "lit/1" do 58 | import Saul, only: [validate: 2, lit: 1] 59 | 60 | test "succeeds when the input term is equal to the term given to lit/1" do 61 | assert validate(3, lit(3)) == {:ok, 3} 62 | 63 | assert {:error, %Error{} = error} = validate(:something_else, lit(:something)) 64 | assert error.reason == "expected exact term :something" 65 | assert error.term == {:term, :something_else} 66 | assert error.validator == nil 67 | end 68 | end 69 | 70 | describe "named_validator/1" do 71 | import Saul, only: [validate: 2, named_validator: 2] 72 | 73 | test "helps produce better error messages" do 74 | validator = fn _term -> {:error, :bad_term} end 75 | named_validator = named_validator(validator, "some validator") 76 | assert {:error, %Error{} = error} = validate(:foo, named_validator) 77 | assert error.reason == ":bad_term" 78 | assert error.validator == "some validator" 79 | 80 | named_validator = named_validator(&is_atom/1, "is_atom guard") 81 | assert {:error, %Error{} = error} = validate("foo", named_validator) 82 | assert error.reason == "predicate failed" 83 | assert error.validator == "is_atom guard" 84 | end 85 | end 86 | 87 | describe "one_of/1" do 88 | import Saul, only: [validate: 2, one_of: 1] 89 | 90 | test "needs at least one validator in the given list" do 91 | assert_raise FunctionClauseError, fn -> one_of([]) end 92 | end 93 | 94 | test "when passed [validator], is the same of just running validator" do 95 | validator = &is_integer/1 96 | assert validate(300, one_of([validator])) == {:ok, 300} 97 | assert {:error, %Error{}} = validate("foo", one_of([validator])) 98 | end 99 | 100 | test "succeeds on the first validator that succeeds, with short circuiting" do 101 | ref = make_ref() 102 | side_effect_validator = fn(_term) -> 103 | Process.put({ref, :side_effect}, true) 104 | {:ok, :ok} 105 | end 106 | 107 | assert validate(22, one_of([&is_integer/1, side_effect_validator])) == {:ok, 22} 108 | refute Process.get({ref, :side_effect}) 109 | end 110 | 111 | test "fails when all validators fail" do 112 | assert {:error, error} = validate(231, one_of([&is_atom/1, &is_binary/1])) 113 | assert error.validator == "one_of" 114 | assert error.reason == 115 | "all validators failed: [(&:erlang.is_binary/1) predicate failed - failing term: 231, " <> 116 | "(&:erlang.is_atom/1) predicate failed - failing term: 231]" 117 | end 118 | end 119 | 120 | describe "all_of/1" do 121 | import Saul, only: [validate: 2, all_of: 1] 122 | 123 | test "needs at least one validator in the given list" do 124 | assert_raise FunctionClauseError, fn -> all_of([]) end 125 | end 126 | 127 | test "when passed [validator], is the same of just running validator" do 128 | validator = &is_integer/1 129 | assert validate(300, all_of([validator])) == {:ok, 300} 130 | assert {:error, %Error{}} = validate("foo", all_of([validator])) 131 | end 132 | 133 | test "fails on the first validator that fails (with short circuiting) and mentions its reason" do 134 | ref = make_ref() 135 | side_effect_validator = fn(_term) -> 136 | Process.put({ref, :side_effect}, true) 137 | {:ok, :ok} 138 | end 139 | 140 | assert {:error, error} = validate(22, all_of([&is_atom/1, side_effect_validator])) 141 | refute Process.get({ref, :side_effect}) 142 | assert Exception.message(error) =~ "predicate failed" 143 | end 144 | 145 | test "succeeds when all validators succeed and returns the result of the last validator" do 146 | to_string = &{:ok, to_string(&1)} 147 | assert validate(:foo, all_of([&is_atom/1, to_string])) == {:ok, "foo"} 148 | end 149 | 150 | test "passes the result of each validator to the next validator" do 151 | to_string = &{:ok, to_string(&1)} 152 | assert validate(:foo, all_of([to_string, &is_binary/1])) == {:ok, "foo"} 153 | end 154 | end 155 | 156 | describe "enum_of/1" do 157 | import Saul, only: [validate: 2, enum_of: 1, enum_of: 2] 158 | 159 | test "ensures that all the elements in an enum satisfy the given validator" do 160 | assert validate([1, 2, 3], enum_of(&is_integer/1)) == {:ok, [1, 2, 3]} 161 | assert {:error, %Error{}} = validate([1, 2, :atom], enum_of(&is_integer/1)) 162 | end 163 | 164 | test "always succeeds when given an empty enum" do 165 | assert validate([], enum_of(fn _ -> false end)) == {:ok, []} 166 | assert validate(%{}, enum_of(fn _ -> false end, into: %{})) == {:ok, %{}} 167 | end 168 | 169 | test "returns a collected value (according to :into) of the transformed values in the enum" do 170 | validator = fn({str, int}) -> {:ok, {String.to_integer(str), int}} end 171 | assert validate(%{"1" => 1, "2" => 2, "3" => 3}, enum_of(validator, into: %{})) == 172 | {:ok, %{1 => 1, 2 => 2, 3 => 3}} 173 | end 174 | end 175 | 176 | describe "map_of/1" do 177 | import Saul, only: [validate: 2, map_of: 2] 178 | 179 | test "validates that the given term is a map" do 180 | assert {:error, %Error{} = error} = validate(:ok, map_of(fn _ -> true end, fn _ -> true end)) 181 | assert Exception.message(error) =~ "predicate failed" 182 | end 183 | 184 | test "validates the type of keys and values in the map" do 185 | atom_to_string = Saul.all_of([&is_atom/1, &{:ok, Atom.to_string(&1)}]) 186 | validator = map_of(atom_to_string, atom_to_string) 187 | 188 | assert validate(%{a: :a, b: :b}, validator) == 189 | {:ok, %{"a" => "a", "b" => "b"}} 190 | 191 | assert {:error, %Error{} = error} = validate(%{"foo" => "bar"}, validator) 192 | assert Exception.message(error) =~ "invalid key" 193 | end 194 | end 195 | 196 | describe "map/2" do 197 | import Saul, only: [validate: 2, map: 1, map: 2] 198 | 199 | test "fails if the second argument (validators map) is not a map" do 200 | assert_raise FunctionClauseError, fn -> 201 | map([], :not_a_map) 202 | end 203 | end 204 | 205 | test "ensures that the given term is a map" do 206 | assert {:error, %Error{} = error} = validate(:ok, map([], %{foo: {:optional, &is_atom/1}})) 207 | assert error.reason =~ "predicate failed" 208 | end 209 | 210 | test "ensures that all the :required keys are present" do 211 | validator = map(%{ 212 | foo: {:required, &is_boolean/1}, 213 | bar: {:required, &is_atom/1}, 214 | }) 215 | 216 | assert {:ok, _} = validate(%{foo: true, bar: :this_is_bar}, validator) 217 | 218 | assert {:error, %Error{} = error} = validate(%{foo: true, missing: :bar}, validator) 219 | assert error.reason == "missing required keys: [:bar]" 220 | end 221 | 222 | test "returns a map with the transformed values for the right keys" do 223 | atom_to_string = Saul.all_of([&is_atom/1, &{:ok, Atom.to_string(&1)}]) 224 | validator = map(%{ 225 | foo: {:required, atom_to_string}, 226 | bar: {:optional, atom_to_string}, 227 | }) 228 | 229 | assert validate(%{foo: :foo, bar: :bar}, validator) == {:ok, %{foo: "foo", bar: "bar"}} 230 | end 231 | 232 | test "returns unknown keys as is if :strict is false" do 233 | atom_to_string = Saul.all_of([&is_atom/1, &{:ok, Atom.to_string(&1)}]) 234 | validator = map([strict: false], %{foo: {:required, atom_to_string}}) 235 | 236 | assert validate(%{foo: :foo, bar: :bar}, validator) == {:ok, %{foo: "foo", bar: :bar}} 237 | end 238 | 239 | test "fails for unknown keys if :strict is true" do 240 | atom_to_string = Saul.all_of([&is_atom/1, &{:ok, Atom.to_string(&1)}]) 241 | validator = map([strict: true], %{foo: {:required, atom_to_string}}) 242 | 243 | assert {:error, error} = validate(%{foo: :foo, bar: :bar}, validator) 244 | assert error.reason == "unknown keys in strict mode: [:bar]" 245 | end 246 | end 247 | 248 | describe "tuple/1" do 249 | import Saul, only: [validate: 2, tuple: 1] 250 | 251 | test "ensures that the given term is a tuple" do 252 | assert {:error, error} = validate(:not_a_tuple, tuple({})) 253 | assert Exception.message(error) =~ "predicate failed" 254 | end 255 | 256 | test "ensures that the given tuple has the right number of elements" do 257 | assert {:error, error} = validate({1, 2, 3}, tuple({&is_integer/1, &is_integer/1})) 258 | assert Exception.message(error) =~ "expected tuple with 2 elements, got one with 3 elements" 259 | end 260 | 261 | test "returns a tuple with the transformed values" do 262 | atom_to_string = &{:ok, Atom.to_string(&1)} 263 | assert validate({:foo, :bar}, tuple({atom_to_string, atom_to_string})) == 264 | {:ok, {"foo", "bar"}} 265 | end 266 | end 267 | 268 | describe "list_of/1" do 269 | import Saul, only: [validate: 2, list_of: 1] 270 | 271 | test "ensures that the given term is a list" do 272 | assert {:error, error} = validate(:ok, list_of(&is_atom/1)) 273 | assert Exception.message(error) =~ "predicate failed" 274 | end 275 | 276 | test "ensures that all the elements in a list satisfy the given validator" do 277 | assert {:ok, _} = validate([1, 2, 3], list_of(&is_integer/1)) 278 | assert {:error, %Error{}} = validate([1, 2, :atom], list_of(&is_integer/1)) 279 | end 280 | 281 | test "always succeeds when given an empty list" do 282 | always_failing_validator = fn(_term) -> {:error, %Error{}} end 283 | assert {:ok, _} = validate([], list_of(always_failing_validator)) 284 | end 285 | 286 | test "returns a list of the transformed values returned by the given validator" do 287 | str_to_int_validator = fn(str) -> {:ok, String.to_integer(str)} end 288 | assert validate(["1", "2", "3"], list_of(str_to_int_validator)) == {:ok, [1, 2, 3]} 289 | end 290 | end 291 | 292 | describe "member/1" do 293 | import Saul, only: [validate: 2, member: 1] 294 | 295 | test "checks for membership in the given enumerable" do 296 | assert validate(4, member([1, :foo, 4, %{}])) == {:ok, 4} 297 | assert validate(99, member(1..100)) == {:ok, 99} 298 | assert validate(:bar, member(MapSet.new([:foo, :bar, :baz]))) == {:ok, :bar} 299 | 300 | assert {:error, %Error{} = error} = validate(1, member(50..100)) 301 | assert error.reason == "not a member of 50..100" 302 | assert error.term == {:term, 1} 303 | end 304 | end 305 | 306 | describe "integration tests" do 307 | test "map/1: goal payload validation" do 308 | formattable_score = Saul.all_of([&is_binary/1, fn(score) -> 309 | case String.split(score, "-", trim: true, parts: 2) do 310 | [left, right] -> 311 | {:ok, {left, right}} 312 | _other -> 313 | {:error, %Error{validator: "formattable_score", reason: "failed to split score: #{inspect(score)}"}} 314 | end 315 | end]) 316 | 317 | validator = Saul.map([strict: true], %{ 318 | "player_name" => {:required, &is_binary/1}, 319 | "score" => {:required, formattable_score}, 320 | "team_side" => {:required, Saul.member([1, 2])}, 321 | "players" => {:optional, Saul.list_of(&is_binary/1)}, 322 | }) 323 | 324 | # map [strict: false], %{ 325 | # "player_name" => {:required, all_of([&is_binary/1, ...])}, 326 | # "players" => {:optional, list_of(&is_binary/1)}, 327 | # } 328 | 329 | assert {:ok, validated} = Saul.validate(%{ 330 | "player_name" => "Cristiano Ronaldo", 331 | "score" => "1-0", 332 | "players" => ["Cristiano Ronaldo", "Gianluigi Buffon"], 333 | "team_side" => 1, 334 | }, validator) 335 | assert validated["score"] == {"1", "0"} 336 | end 337 | end 338 | end 339 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------