├── .credo.exs ├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ └── elixir.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── lib ├── norm.ex └── norm │ ├── conformer.ex │ ├── contract.ex │ ├── core │ ├── all_of.ex │ ├── alt.ex │ ├── any_of.ex │ ├── collection.ex │ ├── delegate.ex │ ├── schema.ex │ ├── selection.ex │ ├── spec.ex │ └── spec │ │ ├── and.ex │ │ └── or.ex │ ├── errors.ex │ ├── generatable.ex │ └── generator.ex ├── mix.exs ├── mix.lock └── test ├── norm ├── contract_test.exs ├── core │ ├── alt_test.exs │ ├── any_of_test.exs │ ├── collection_test.exs │ ├── delegate_test.exs │ ├── schema_test.exs │ ├── selection_test.exs │ └── spec_test.exs ├── generator_test.exs └── generators │ └── primitives_test.exs ├── norm_test.exs ├── support ├── norm_case.ex └── user.ex └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any exec using `mix credo -C `. If no exec name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: ["lib/", "test/"], 25 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 26 | }, 27 | # 28 | # Load and configure plugins here: 29 | # 30 | plugins: [], 31 | # 32 | # If you create your own checks, you must specify the source files for 33 | # them here, so they can be loaded by Credo before running the analysis. 34 | # 35 | requires: [], 36 | # 37 | # If you want to enforce a style guide and need a more traditional linting 38 | # experience, you can change `strict` to `true` below: 39 | # 40 | strict: false, 41 | # 42 | # If you want to use uncolored output by default, you can change `color` 43 | # to `false` below: 44 | # 45 | color: true, 46 | # 47 | # You can customize the parameters of any check by adding a second element 48 | # to the tuple. 49 | # 50 | # To disable a check put `false` as second element: 51 | # 52 | # {Credo.Check.Design.DuplicatedCode, false} 53 | # 54 | checks: [ 55 | # 56 | ## Consistency Checks 57 | # 58 | {Credo.Check.Consistency.ExceptionNames, []}, 59 | {Credo.Check.Consistency.LineEndings, []}, 60 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 61 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 62 | {Credo.Check.Consistency.SpaceAroundOperators, false}, 63 | {Credo.Check.Consistency.SpaceInParentheses, false}, 64 | {Credo.Check.Consistency.TabsOrSpaces, []}, 65 | {Credo.Check.Consistency.UnusedVariableNames, false}, 66 | 67 | # 68 | ## Design Checks 69 | # 70 | # You can customize the priority of any check 71 | # Priority values are: `low, normal, high, higher` 72 | # 73 | {Credo.Check.Design.AliasUsage, 74 | [priority: :low, if_nested_deeper_than: 4, if_called_more_often_than: 2]}, 75 | {Credo.Check.Design.DuplicatedCode, false}, 76 | {Credo.Check.Design.TagTODO, false}, 77 | {Credo.Check.Design.TagFIXME, false}, 78 | 79 | # 80 | ## Readability Checks 81 | # 82 | {Credo.Check.Readability.AliasAs, false}, 83 | {Credo.Check.Readability.AliasOrder, false}, 84 | {Credo.Check.Readability.FunctionNames, []}, 85 | {Credo.Check.Readability.LargeNumbers, []}, 86 | {Credo.Check.Readability.MaxLineLength, false}, 87 | {Credo.Check.Readability.ModuleAttributeNames, []}, 88 | {Credo.Check.Readability.ModuleDoc, []}, 89 | {Credo.Check.Readability.ModuleNames, []}, 90 | {Credo.Check.Readability.MultiAlias, false}, 91 | {Credo.Check.Readability.ParenthesesInCondition, []}, 92 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 93 | {Credo.Check.Readability.PredicateFunctionNames, false}, 94 | {Credo.Check.Readability.PreferImplicitTry, []}, 95 | {Credo.Check.Readability.RedundantBlankLines, [max_blank_lines: 1]}, 96 | {Credo.Check.Readability.Semicolons, []}, 97 | {Credo.Check.Readability.SinglePipe, false}, 98 | {Credo.Check.Readability.SpaceAfterCommas, false}, 99 | {Credo.Check.Readability.Specs, false}, 100 | {Credo.Check.Readability.StringSigils, []}, 101 | {Credo.Check.Readability.TrailingBlankLine, []}, 102 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 103 | # TODO: enable by default in Credo 1.1 104 | {Credo.Check.Readability.UnnecessaryAliasExpansion, false}, 105 | {Credo.Check.Readability.VariableNames, []}, 106 | 107 | # 108 | # Controversial and experimental checks (opt-in, just replace `false` with `[]`) 109 | # 110 | 111 | # 112 | ## Refactoring Opportunities 113 | # 114 | {Credo.Check.Refactor.ABCSize, false}, 115 | {Credo.Check.Refactor.AppendSingleItem, false}, 116 | {Credo.Check.Refactor.CaseTrivialMatches, false}, 117 | {Credo.Check.Refactor.CondStatements, false}, 118 | {Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 18]}, 119 | {Credo.Check.Refactor.DoubleBooleanNegation, false}, 120 | {Credo.Check.Refactor.FunctionArity, []}, 121 | {Credo.Check.Refactor.LongQuoteBlocks, false}, 122 | {Credo.Check.Refactor.MapInto, false}, 123 | {Credo.Check.Refactor.MatchInCondition, false}, 124 | {Credo.Check.Refactor.ModuleDependencies, false}, 125 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 126 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 127 | {Credo.Check.Refactor.Nesting, false}, 128 | {Credo.Check.Refactor.PipeChainStart, false}, 129 | {Credo.Check.Refactor.UnlessWithElse, []}, 130 | {Credo.Check.Refactor.VariableRebinding, false}, 131 | {Credo.Check.Refactor.WithClauses, []}, 132 | 133 | # 134 | ## Warnings 135 | # 136 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 137 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 138 | {Credo.Check.Warning.IExPry, []}, 139 | {Credo.Check.Warning.IoInspect, []}, 140 | {Credo.Check.Warning.LazyLogging, false}, 141 | {Credo.Check.Warning.MapGetUnsafePass, false}, 142 | {Credo.Check.Warning.OperationOnSameValues, []}, 143 | {Credo.Check.Warning.OperationWithConstantResult, false}, 144 | {Credo.Check.Warning.RaiseInsideRescue, []}, 145 | {Credo.Check.Warning.UnsafeToAtom, false}, 146 | {Credo.Check.Warning.UnusedEnumOperation, []}, 147 | {Credo.Check.Warning.UnusedFileOperation, []}, 148 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 149 | {Credo.Check.Warning.UnusedListOperation, []}, 150 | {Credo.Check.Warning.UnusedPathOperation, []}, 151 | {Credo.Check.Warning.UnusedRegexOperation, []}, 152 | {Credo.Check.Warning.UnusedStringOperation, []}, 153 | {Credo.Check.Warning.UnusedTupleOperation, []} 154 | ] 155 | } 156 | ] 157 | } 158 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "10:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: ex_doc 11 | versions: 12 | - 0.24.0 13 | - 0.24.1 14 | - dependency-name: credo 15 | versions: 16 | - 1.5.4 17 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | env: 10 | MIX_ENV: test 11 | 12 | jobs: 13 | deps: 14 | name: Install Dependencies 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | elixir: [1.13] 19 | otp: [25.3] 20 | steps: 21 | - name: checkout 22 | uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | - name: setup 26 | uses: erlef/setup-beam@v1 27 | with: 28 | elixir-version: ${{ matrix.elixir }} 29 | otp-version: ${{ matrix.otp }} 30 | - name: Retrieve Cached Dependencies 31 | uses: actions/cache@v4 32 | id: mix-cache 33 | with: 34 | path: | 35 | deps 36 | _build 37 | priv/plts 38 | key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('mix.lock') }} 39 | - name: Install deps 40 | if: steps.mix-cache.outputs.cache-hit != 'true' 41 | run: | 42 | mkdir -p priv/plts 43 | mix local.rebar --force 44 | mix local.hex --force 45 | mix deps.get 46 | mix deps.compile 47 | 48 | analyze: 49 | name: Analysis 50 | needs: deps 51 | runs-on: ubuntu-latest 52 | strategy: 53 | matrix: 54 | elixir: [1.13] 55 | otp: [25.3] 56 | steps: 57 | - uses: actions/checkout@v4 58 | with: 59 | fetch-depth: 0 60 | - name: Setup elixir 61 | uses: erlef/setup-beam@v1 62 | with: 63 | elixir-version: ${{ matrix.elixir }} 64 | otp-version: ${{ matrix.otp }} 65 | - name: Retrieve Cached Dependencies 66 | uses: actions/cache@v4 67 | id: mix-cache 68 | with: 69 | path: | 70 | deps 71 | _build 72 | priv/plts 73 | key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('mix.lock') }} 74 | - name: Run Credo 75 | run: mix credo 76 | 77 | tests: 78 | name: Tests 79 | needs: deps 80 | runs-on: ubuntu-latest 81 | strategy: 82 | matrix: 83 | elixir: [1.13] 84 | otp: [25.3] 85 | steps: 86 | - uses: actions/checkout@v4 87 | with: 88 | fetch-depth: 0 89 | - uses: erlef/setup-beam@v1 90 | with: 91 | elixir-version: ${{ matrix.elixir }} 92 | otp-version: ${{ matrix.otp }} 93 | - name: Retrieve Cached Dependencies 94 | uses: actions/cache@v4 95 | id: mix-cache 96 | with: 97 | path: | 98 | deps 99 | _build 100 | priv/plts 101 | key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('mix.lock') }} 102 | - name: Run Tests 103 | run: mix test 104 | -------------------------------------------------------------------------------- /.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 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | norm-*.tar 24 | 25 | # Elixir Language Server (e.g. for VS Code users). 26 | /.elixir_ls -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 0.13.1 (January, 10, 2025) 4 | 5 | - relax StreamData version constraint #149 6 | 7 | ## 0.13.0 (June, 25, 2021) 8 | 9 | * Bug fix for selections 10 | * Added delegate for recursive schemas 11 | 12 | ## 0.12.0 (May 12, 2020) 13 | 14 | * Added reflection and other improvements to contracts - Wojtek Mach 15 | 16 | ## 0.11.0 (April 11, 2020) 17 | 18 | * Add more primitive generators 19 | * Fix formatting error when selecting unknown keys in a schema 20 | * contracts no longer require predicates to include parens 21 | 22 | ## 0.10.4 (March 10, 2020) 23 | 24 | * [ffe49b3](https://github.com/keathley/norm/commit/ffe49b39dc3cf89c659e91f6958f938b5c6de5c1) Use GitHub CI - Wojtek Mach 25 | * [1fa941b](https://github.com/keathley/norm/commit/1fa941b496463b682b18dcb8c31aedf4e50d5b60) Conform collection values using the correct types - Chris Keathley 26 | * [3313b1e](https://github.com/keathley/norm/commit/3313b1eae8398c2d61daab8d64f7d4af7a522f82) Rearrange Norm's internal AST directory - Chris Keathley 27 | * [e6ae160](https://github.com/keathley/norm/commit/e6ae160f23e382a30edc00e678b4c176025031cd) don't crash if using nested selection with non-map input - Chris Keathley 28 | 29 | ## 0.10.3 (January 31, 2020) 30 | * [2aa1173](https://github.com/keathley/norm/commit/2aa1173a370d6ba37bca193dc46ef1e302c9216b) Stop selection from duplicating errors with nested schemas - Chris Keathley 31 | * [9ba0261](https://github.com/keathley/norm/commit/9ba0261e91b200fd6807b64e820c4b7490fbc2eb) Merge branch 'return-single-error-from-selection' - Chris Keathley 32 | * [f58631b](https://github.com/keathley/norm/commit/f58631beda926762a01c4e2d995b2d621ecb66a3) Implement inspect for the other structs in Norm - Chris Keathley 33 | * [0997e06](https://github.com/keathley/norm/commit/0997e06edd259eae5267abc806fb1a0d59bc087e) Merge branch 'implement-inspect' - Chris Keathley 34 | * [3f88509](https://github.com/keathley/norm/commit/3f8850912dc07ed06895e36d3419af1e77ef23a8) Allow ellision of parens on single arity functions - Chris Keathley 35 | * [5be61af](https://github.com/keathley/norm/commit/5be61afc8f5b47297685bf3dcbc8e9bece0b494d) Merge branch 'allow-predicates-without-parens' - Chris Keathley 36 | * [6bf487d](https://github.com/keathley/norm/commit/6bf487de2151fdb61c62210e22cefd4a96a41ea9) Return errors from selections correctly - Chris Keathley 37 | * [6a5c8b2](https://github.com/keathley/norm/commit/6a5c8b2bc97d49b406b68074406dd205211c21f3) Merge branch 'error-if-selection-specifies-key-not-in-schema' - Chris Keathley 38 | * [31ad460](https://github.com/keathley/norm/commit/31ad460710d88d01189188d1f38b78700891362c) Allow structs to conform with default keys - Chris Keathley 39 | * [ee46655](https://github.com/keathley/norm/commit/ee466552e423d083a1ae2ffef7d114e56038040f) Merge branch 'allow-struct-schemas-to-use-defaults' - Chris Keathley 40 | * [1c950e1](https://github.com/keathley/norm/commit/1c950e1aac7d510c67ca712a97e0e2bf521419b9) Always return selection errors - Chris Keathley 41 | * [f99a122](https://github.com/keathley/norm/commit/f99a1229f8c109ee16493aa48f319287725f13f0) Merge branch 'always-return-selection-errors' - Chris Keathley 42 | 43 | ## 0.10.2 (January 20, 2020) 44 | 45 | * [1a5e6ce](https://github.com/keathley/norm/commit/1a5e6ce7b0ace069342885e71b9fdfffd0fe0ee6) Handle selections around structs with nested maps - Chris Keathley 46 | * [dc32fab](https://github.com/keathley/norm/commit/dc32fab3adade4a5b3b6ab76dbc9d2a9d84b1d13) Merge branch 'selection-on-structs' - Chris Keathley 47 | 48 | ## 0.10.1 (January 14, 2020) 49 | 50 | * [45c05a0](https://github.com/keathley/norm/commit/45c05a003b0aa213b2e15803fa3130bc59a7c869) Don't raise exceptions when trying to conform tuples - Chris Keathley 51 | * [2d50cd7](https://github.com/keathley/norm/commit/2d50cd7f869eb63637b103e3ef378080c2571c18) Merge branch 'fix-exception-in-tuple-conformer' - Chris Keathley 52 | 53 | ## 0.10.0 (December 30, 2019) 54 | 55 | * [28105e6](https://github.com/keathley/norm/commit/28105e6d77245e9d21221028f25f860ab413e597) Contracts - Wojtek Mach 56 | * [02221e6](https://github.com/keathley/norm/commit/02221e6a1f02cc15177df651591fff8e96089fc4) Add docs about contracts to README - Chris Keathley 57 | 58 | ## 0.9.2 (December 09, 2019) 59 | 60 | * [cc9ea68](https://github.com/keathley/norm/commit/cc9ea6856ba9a08402d6077f749348d61c247565) Don't flatten good results - Chris Keathley 61 | 62 | ## 0.9.1 (December 03, 2019) 63 | 64 | * [52dbe8a](https://github.com/keathley/norm/commit/52dbe8a19906c4d32311d09fbc666fccb0b45d2e) Conform struct input with map schemas 65 | 66 | ## 0.9.0 (December 02, 2019) 67 | 68 | * [fe5fc68](https://github.com/keathley/norm/commit/fe5fc682bb9b4fd1ce03c9068303751193c33cdb) Changes to optionality in schema's and selection 69 | 70 | ## 0.8.1 (November 24, 2019) 71 | 72 | * [41bff7a](https://github.com/keathley/norm/commit/41bff7a4af1296bc16eff298249f01546fcf245d) Add Credo Support - Joey Rosztoczy 73 | * [7f2773f](https://github.com/keathley/norm/commit/7f2773fd4fb488a9a4c0a42c9bf3a7bf689eda55) Doc fixes - Brett Wise 74 | * [8880dd5](https://github.com/keathley/norm/commit/8880dd590dc798aa306c19b8bcef4a0514bad498) Doc fixes - Kevin Baird 75 | * [172edad](https://github.com/keathley/norm/commit/172edad05070f7d997fdb3dcfb43f1978188039c) `coll_of` and `map_of` fixes - Stefan Fochler 76 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Christopher Jon Keathley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Norm 2 | 3 | 4 | 5 | Norm is a system for specifying the structure of data. It can be used for 6 | validation and for generation of data. Norm does not provide any set of 7 | predicates and instead allows you to re-use any of your existing 8 | validations. 9 | 10 | ```elixir 11 | import Norm 12 | 13 | iex> conform!(123, spec(is_integer() and &(&1 > 0))) 14 | 123 15 | 16 | iex> conform!(-50, spec(is_integer() and &(&1 > 0))) 17 | ** (Norm.MismatchError) Could not conform input: 18 | val: -50 fails: &(&1 > 0) 19 | 20 | iex> user_schema = schema(%{ 21 | ...> user: schema(%{ 22 | ...> name: spec(is_binary()), 23 | ...> age: spec(is_integer() and &(&1 > 0)) 24 | ...> }) 25 | ...> }) 26 | iex> input = %{user: %{name: "chris", age: 30, email: "c@keathley.io"}} 27 | iex> conform!(input, user_schema) 28 | %{user: %{name: "chris", age: 30, email: "c@keathley.io"}} 29 | iex> generated_users = 30 | ...> user_schema 31 | ...> |> gen() 32 | ...> |> Enum.take(3) 33 | iex> for g <- generated_users, do: g.user.age > 0 && is_binary(g.user.name) 34 | [true, true, true] 35 | ``` 36 | 37 | Norm can also be used to specify contracts for function definitions: 38 | 39 | ```elixir 40 | defmodule Colors do 41 | use Norm 42 | 43 | def rgb(), do: spec(is_integer() and &(&1 in 0..255)) 44 | 45 | def hex(), do: spec(is_binary() and &String.starts_with?(&1, "#")) 46 | 47 | @contract rgb_to_hex(r :: rgb(), g :: rgb(), b :: rgb()) :: hex() 48 | def rgb_to_hex(r, g, b) do 49 | # ... 50 | end 51 | end 52 | ``` 53 | 54 | ## Validation and conforming values 55 | 56 | Norm validates data by "conforming" the value to a specification. If the 57 | values don't conform then a list of errors is returned. There are 58 | 2 functions provided for this `conform/2` and `conform!/2`. If you need to 59 | return a list of well defined errors then you should use `conform/2`. 60 | Otherwise `conform!/2` is generally more useful. The input data is 61 | always passed as the 1st argument to `conform` so that calls to conform 62 | are easily chainable. 63 | 64 | ### Predicates and specs 65 | 66 | Norm does not provide a special set of predicates and instead allows you 67 | to convert any predicate into a spec with the `spec/1` macro. Predicates 68 | can be composed together using the `and` and `or` keywords. You can also 69 | use anonymous functions to create specs. 70 | 71 | ```elixir 72 | spec(is_binary()) 73 | spec(is_integer() and &(&1 > 0)) 74 | spec(is_binary() and fn str -> String.length(str) > 0 end) 75 | ``` 76 | 77 | The data is always passed as the first argument to your predicate so you 78 | can use predicates with multiple values like so: 79 | 80 | ```elixir 81 | iex> defmodule Predicate do 82 | ...> def greater?(x, y), do: x > y 83 | ...> end 84 | iex> conform!(10, spec(Predicate.greater?(5))) 85 | 10 86 | iex> conform!(3, spec(Predicate.greater?(5))) 87 | ** (Norm.MismatchError) Could not conform input: 88 | val: 3 fails: Predicate.greater?(5) 89 | ``` 90 | 91 | ### Tuples and atoms 92 | 93 | Atoms and tuples can be matched without needing to wrap them in a function. 94 | 95 | ```elixir 96 | iex> :atom = conform!(:atom, :atom) 97 | :atom 98 | iex> {1, "hello"} = conform!({1, "hello"}, {spec(is_integer()), spec(is_binary())}) 99 | {1, "hello"} 100 | iex> conform!({1, 2}, {:one, :two}) 101 | ** (Norm.MismatchError) Could not conform input: 102 | val: 1 in: 0 fails: is not an atom. 103 | val: 2 in: 1 fails: is not an atom. 104 | ``` 105 | 106 | Because Norm supports matching on bare tuples we can easily validate functions 107 | that return `{:ok, term()}` and `{:error, term()}` tuples. These specifications can be combined with `one_of/1` to create union types. 108 | 109 | ```elixir 110 | iex> defmodule User do 111 | ...> defstruct [:name, :age] 112 | ...> 113 | ...> def get_name(id) do 114 | ...> case id do 115 | ...> 1 -> {:ok, "Chris"} 116 | ...> 2 -> {:ok, "Alice"} 117 | ...> _ -> {:error, "user does not exist"} 118 | ...> end 119 | ...> end 120 | ...> end 121 | iex> result_spec = one_of([ 122 | ...> {:ok, spec(is_binary())}, 123 | ...> {:error, spec(fn _ -> true end)}, 124 | ...> ]) 125 | iex> {:ok, _name} = conform!(User.get_name(1), result_spec) 126 | {:ok, "Chris"} 127 | iex> {:ok, "Alice"} = conform!(User.get_name(2), result_spec) 128 | {:ok, "Alice"} 129 | iex> {:error, _error} = conform!(User.get_name(-42), result_spec) 130 | {:error, "user does not exist"} 131 | ``` 132 | 133 | ### Collections 134 | 135 | Norm can define collections of values using `coll_of`. 136 | 137 | ```elixir 138 | iex> conform!([1,2,3], coll_of(spec(is_integer))) 139 | [1, 2, 3] 140 | ``` 141 | 142 | Collections can take a number of options: 143 | 144 | * `:kind` - predicate function the kind of collection being conformed 145 | * `:distinct` - boolean value for specifying if the collection should have distinct elements 146 | * `:min_count` - Minimum element count 147 | * `:max_count` - Maximum element count 148 | * `:into` - The output collection the input will be conformed into. If not specified then the input type will be used. 149 | 150 | ```elixir 151 | iex> conform!([:a, :b, :c], coll_of(spec(is_atom), into: MapSet.new())) 152 | #MapSet<[:a, :b, :c]> 153 | ``` 154 | 155 | ### Schemas 156 | 157 | Norm provides a `schema/1` function for specifying maps and structs: 158 | 159 | ```elixir 160 | iex> user_schema = schema(%{ 161 | ...> user: schema(%{ 162 | ...> name: spec(is_binary()), 163 | ...> age: spec(is_integer() and & &1 > 0), 164 | ...> }) 165 | ...> }) 166 | iex> conform!(%{user: %{name: "chris", age: 31}}, user_schema) 167 | %{user: %{name: "chris", age: 31}} 168 | iex> conform!(%{user: %{name: "chris", age: -31}}, user_schema) 169 | ** (Norm.MismatchError) Could not conform input: 170 | val: -31 in: :user/:age fails: &(&1 > 0) 171 | ``` 172 | 173 | Schema's are designed to allow systems to grow over time. They provide this 174 | functionality in two ways. The first is that any unspecified fields in the input 175 | are passed through when conforming the input. The second is that all keys in a 176 | schema are optional. This means that all of these are valid: 177 | 178 | ```elixir 179 | iex> user_schema = schema(%{ 180 | ...> name: spec(is_binary()), 181 | ...> age: spec(is_integer()), 182 | ...> }) 183 | iex> conform!(%{}, user_schema) 184 | %{} 185 | iex> conform!(%{age: 31}, user_schema) 186 | %{age: 31} 187 | iex> conform!(%{foo: :foo, bar: :bar}, user_schema) 188 | %{foo: :foo, bar: :bar} 189 | ``` 190 | 191 | If you're used to more restrictive systems for managing data these might seem 192 | like odd choices. We'll see how to specify required keys when we discuss Selections. 193 | 194 | #### Structs 195 | 196 | You can also create specs from structs: 197 | 198 | ```elixir 199 | defmodule User do 200 | defstruct [:name, :age] 201 | 202 | def s, do: schema(%__MODULE__{ 203 | name: spec(is_binary()), 204 | age: spec(is_integer()) 205 | }) 206 | end 207 | ``` 208 | 209 | This will ensure that the input is a `User` struct with the key that match 210 | the given specification. Its convention to provide a `s()` function in the 211 | module that defines the struct so that schema's can be shared throughout 212 | your system. 213 | 214 | You don't need to provide specs for all the keys in your struct. Only the 215 | specced keys will be conformed. The remaining keys will be checked for 216 | presence. 217 | 218 | ```elixir 219 | defmodule Norm.User do 220 | defstruct [:name, :age] 221 | end 222 | iex> user_schema = schema(%Norm.User{}) 223 | iex> conform!(%Norm.User{name: "chris"}, user_schema) 224 | ``` 225 | 226 | #### Key semantics 227 | 228 | Atom and string keys are matched explicitly and there is no casting that 229 | occurs when conforming values. If you need to match on string keys you 230 | should specify your schema with string keys. 231 | 232 | Schemas accomodate growth by disregarding any unspecified keys in the input map. 233 | This allows callers to start sending new data over time without coordination 234 | with the consuming function. 235 | 236 | ### Selections and optionality 237 | 238 | We said that all of the fields in a schema are optional. In order to specify 239 | the keys that are required in a specific use case we can use a Selection. The 240 | Selections takes a schema and a list of keys - or keys to lists of keys - that 241 | must be present in the schema. 242 | 243 | ```elixir 244 | iex> user_schema = schema(%{ 245 | ...> user: schema(%{ 246 | ...> name: spec(is_binary()), 247 | ...> age: spec(is_integer()), 248 | ...> }) 249 | ...> }) 250 | iex> just_age = selection(user_schema, [user: [:age]]) 251 | iex> conform!(%{user: %{name: "chris", age: 31}}, just_age) 252 | %{user: %{age: 31, name: "chris"}} 253 | iex> conform!(%{user: %{name: "chris"}}, just_age) 254 | ** (Norm.MismatchError) Could not conform input: 255 | val: %{name: "chris"} in: :user/:age fails: :required 256 | ``` 257 | 258 | If you need to mark all fields in a schema as required you can elide the list 259 | of keys like so: 260 | 261 | ```elixir 262 | iex> user_schema = schema(%{ 263 | ...> user: schema(%{ 264 | ...> name: spec(is_binary()), 265 | ...> age: spec(is_integer()), 266 | ...> }) 267 | ...> }) 268 | iex> conform!(%{user: %{name: "chris", age: 31}}, selection(user_schema)) 269 | %{user: %{name: "chris", age: 31}} 270 | ``` 271 | 272 | Selections are an important tool because they give control over optionality 273 | back to the call site. This allows callers to determine what they actually need 274 | and makes schema's much more reusable. 275 | 276 | ### Patterns 277 | 278 | Norm provides a way to specify alternative specs using the `alt/1` 279 | function. This is useful when you need to support multiple schema's or 280 | multiple alternative specs. 281 | 282 | ```elixir 283 | iex> create_event = schema(%{type: spec(&(&1 == :create))}) 284 | iex> update_event = schema(%{type: spec(&(&1 == :update))}) 285 | iex> event = alt(create: create_event, update: update_event) 286 | iex> conform!(%{type: :create}, event) 287 | {:create, %{type: :create}} 288 | iex> conform!(%{type: :update}, event) 289 | {:update, %{type: :update}} 290 | iex> conform!(%{type: :delete}, event) 291 | ** (Norm.MismatchError) Could not conform input: 292 | val: :delete in: :create/:type fails: &(&1 == :create) 293 | val: :delete in: :update/:type fails: &(&1 == :update) 294 | ``` 295 | 296 | ## Generators 297 | 298 | Along with validating that data conforms to a given specification, Norm 299 | can also use specificiations to generate examples of good data. These 300 | examples can then be used for property based testing, local development, 301 | seeding databases, or any other use case. 302 | 303 | ```elixir 304 | iex> user_schema = schema(%{ 305 | ...> name: spec(is_binary()), 306 | ...> age: spec(is_integer() and &(&1 > 0)) 307 | ...> }) 308 | iex> generated = 309 | ...> user_schema 310 | ...> |> gen() 311 | ...> |> Enum.take(3) 312 | iex> for user <- generated, do: user.age > 0 && is_binary(user.name) 313 | [true, true, true] 314 | ``` 315 | 316 | Under the hood Norm uses StreamData for its data generation. This means 317 | you can use your specs in tests like so: 318 | 319 | ```elixir 320 | input_data = schema(%{"user" => schema(%{"name" => spec(is_binary())})}) 321 | 322 | property "users can update names" do 323 | check all input <- gen(input_data) do 324 | assert :ok == update_user(input) 325 | end 326 | end 327 | ``` 328 | 329 | ### Built in generators 330 | 331 | Norm will try to infer the generator to use from the predicate defined in 332 | `spec`. It looks specifically for the guard clauses used for primitive 333 | types in elixir. Not all of the built in guard clauses are supported yet. 334 | PRs are very welcome ;). 335 | 336 | ### Guiding generators 337 | 338 | You may have specs like `spec(fn x -> rem(x, 2) == 0 end)` which check to 339 | see that an integer is even or not. This generator expects integer values 340 | but there's no way for Norm to determine this. If you try to create 341 | a generator from this spec you'll get an error: 342 | 343 | ```elixir 344 | gen(spec(fn x -> rem(x, 2) == 0 end)) 345 | ** (Norm.GeneratorError) Unable to create a generator for: fn x -> rem(x, 2) == 0 end 346 | (norm) lib/norm.ex:76: Norm.gen/1 347 | ``` 348 | 349 | You can guide Norm to the right generator by specifying a guard clause as 350 | the first predicate in a spec. If Norm can find the right generator then 351 | it will use any other predicates as filters in the generator. 352 | 353 | ```elixir 354 | Enum.take(gen(spec(is_integer() and fn x -> rem(x, 2) == 0 end)), 5) 355 | [0, -2, 2, 0, 4] 356 | ``` 357 | 358 | But its also possible to create filters that are too specific such as 359 | this: 360 | 361 | ```elixir 362 | gen(spec(is_binary() and &(&1 =~ ~r/foobarbaz/))) 363 | ``` 364 | 365 | Norm can determine the generators to use however its incredibly unlikely 366 | that Norm will be able to generate data that matches the filter. After 25 367 | consecutive unseccessful attempts to generate a good value Norm (StreamData 368 | under the hood) will return an error. In these scenarios we can create 369 | a custom generator. 370 | 371 | ### Overriding generators 372 | 373 | You'll often need to guide your generators into the interesting parts of the 374 | state space so that you can easily find bugs. That means you'll want to tweak 375 | and control your generators. Norm provides an escape hatch for creating your 376 | own generators with the `with_gen/2` function: 377 | 378 | ```elixir 379 | age = spec(is_integer() and &(&1 >= 0)) 380 | reasonable_ages = with_gen(age, StreamData.integer(0..105)) 381 | ``` 382 | 383 | Because `gen/1` returns a StreamData generator you can compose your generators 384 | with other StreamData functions: 385 | 386 | ```elixir 387 | age = spec(is_integer() and &(&1 >= 0)) 388 | StreamData.frequency([ 389 | {3, gen(age)}, 390 | {1, StreamData.binary()}, 391 | ]) 392 | 393 | gen(age) |> StreamData.map(&Integer.to_string/1) |> Enum.take(5) 394 | ["1", "1", "3", "4", "1"] 395 | ``` 396 | 397 | This allows you to compose generators however you need to while keeping your 398 | generation co-located with the specification of the data. 399 | 400 | ## Adding contracts to functions 401 | 402 | You can `conform` data wherever it makes sense to do so in your application. 403 | But one of the most common ways to use Norm is to validate a functions arguments 404 | and return value. Because this is such a common pattern, Norm provides function 405 | annotations similar to `@spec`: 406 | 407 | ```elixir 408 | defmodule Colors do 409 | use Norm 410 | 411 | def rgb(), do: spec(is_integer() and &(&1 in 0..255)) 412 | 413 | def hex(), do: spec(is_binary() and &String.starts_with?(&1, "#")) 414 | 415 | @contract rgb_to_hex(r :: rgb(), g :: rgb(), b :: rgb()) :: hex() 416 | @doc "Convert an RGB value to its CSS-style hexadecimal notation." 417 | def rgb_to_hex(r, g, b) do 418 | # ... 419 | end 420 | end 421 | ``` 422 | 423 | If the arguments for `rgb_to_hex` don't conform to the specification or if 424 | `rgb_to_hex` does not return a value that conforms to `hex` then an error will 425 | be raised. 426 | 427 | Note `@contract` must be placed _before_ `@doc` as above for ExDoc and 428 | `ExUnit.DocTest` to continue working as intended. 429 | 430 | 431 | 432 | ## Installation 433 | 434 | Add `norm` to your list of dependencies in `mix.exs`. If you'd like to use 435 | Norm's generator capabilities then you'll also need to include StreamData 436 | as a dependency. 437 | 438 | ```elixir 439 | def deps do 440 | [ 441 | {:stream_data, "~> 0.4"}, 442 | {:norm, "~> 0.13"} 443 | ] 444 | end 445 | ``` 446 | 447 | ## Should I use this? 448 | 449 | Norm is still early in its life so there may be some rough edges. But 450 | we're actively using this at my current company (Bleacher Report) and 451 | working to make improvements. 452 | 453 | ## Contributing and TODOS 454 | 455 | Norm is being actively worked on. Any contributions are very welcome. Here is a 456 | limited set of ideas that are coming soon. 457 | 458 | - [ ] More streamlined specification of keyword lists. 459 | - [ ] Support "sets" of literal values 460 | - [ ] specs for functions and anonymous functions 461 | - [ ] easier way to do dispatch based on schema keys 462 | -------------------------------------------------------------------------------- /lib/norm.ex: -------------------------------------------------------------------------------- 1 | defmodule Norm do 2 | @external_resource "README.md" 3 | 4 | @moduledoc "README.md" 5 | |> File.read!() 6 | |> String.split("") 7 | |> Enum.fetch!(1) 8 | 9 | alias Norm.Conformer 10 | alias Norm.Generatable 11 | alias Norm.Generator 12 | alias Norm.MismatchError 13 | alias Norm.GeneratorError 14 | alias Norm.Core.{ 15 | Alt, 16 | AnyOf, 17 | Collection, 18 | Schema, 19 | Selection, 20 | Spec, 21 | Delegate 22 | } 23 | 24 | @doc false 25 | defmacro __using__(_) do 26 | quote do 27 | import Norm 28 | use Norm.Contract 29 | end 30 | end 31 | 32 | @doc ~S""" 33 | Verifies that the payload conforms to the specification. A "success tuple" 34 | is returned that contains either the conformed value or the error explanation. 35 | 36 | ## Examples: 37 | 38 | iex> conform(42, spec(is_integer())) 39 | {:ok, 42} 40 | iex> conform(42, spec(fn x -> x == 42 end)) 41 | {:ok, 42} 42 | iex> conform(42, spec(&(&1 >= 0))) 43 | {:ok, 42} 44 | iex> conform(42, spec(&(&1 >= 100))) 45 | {:error, [%{spec: "&(&1 >= 100)", input: 42, path: []}]} 46 | iex> conform("foo", spec(is_integer())) 47 | {:error, [%{spec: "is_integer()", input: "foo", path: []}]} 48 | """ 49 | def conform(input, spec) do 50 | Conformer.conform(spec, input) 51 | end 52 | 53 | @doc ~s""" 54 | Returns the conformed value or raises a mismatch error. 55 | 56 | ## Examples 57 | 58 | iex> conform!(42, spec(is_integer())) 59 | 42 60 | iex> conform!(42, spec(is_binary())) 61 | ** (Norm.MismatchError) Could not conform input: 62 | val: 42 fails: is_binary() 63 | """ 64 | def conform!(input, spec) do 65 | case Conformer.conform(spec, input) do 66 | {:ok, input} -> input 67 | {:error, errors} -> raise MismatchError, errors 68 | end 69 | end 70 | 71 | @doc ~S""" 72 | Checks if the value conforms to the spec and returns a boolean. 73 | 74 | ## Examples 75 | 76 | iex> valid?(42, spec(is_integer())) 77 | true 78 | iex> valid?("foo", spec(is_integer())) 79 | false 80 | """ 81 | def valid?(input, spec) do 82 | case Conformer.conform(spec, input) do 83 | {:ok, _} -> true 84 | {:error, _} -> false 85 | end 86 | end 87 | 88 | @doc ~S""" 89 | Creates a generator from a spec, schema, or selection. 90 | 91 | ## Examples 92 | 93 | iex> gen(spec(is_integer())) |> Enum.take(3) |> Enum.all?(&is_integer/1) 94 | true 95 | iex> gen(spec(is_binary())) |> Enum.take(3) |> Enum.all?(&is_binary/1) 96 | true 97 | iex> gen(spec(&(&1 > 0))) 98 | ** (Norm.GeneratorError) Unable to create a generator for: &(&1 > 0) 99 | """ 100 | def gen(spec) do 101 | if Code.ensure_loaded?(StreamData) do 102 | case Generatable.gen(spec) do 103 | {:ok, generator} -> generator 104 | {:error, error} -> raise GeneratorError, error 105 | end 106 | else 107 | raise Norm.GeneratorLibraryError 108 | end 109 | end 110 | 111 | @doc """ 112 | Overwrites the default generator with a custom generator. The generator 113 | can be any valid StreamData generator. This means you can either use Norms 114 | built in `gen/1` function or you can drop into StreamData directly. 115 | 116 | ## Examples 117 | 118 | iex> Enum.take(gen(with_gen(spec(is_integer()), StreamData.constant("hello world"))), 3) 119 | ["hello world", "hello world", "hello world"] 120 | """ 121 | if Code.ensure_loaded?(StreamData) do 122 | def with_gen(spec, %StreamData{} = generator) do 123 | Generator.new(spec, generator) 124 | end 125 | else 126 | def with_gen(spec, _) do 127 | Generator.new(spec, :null) 128 | end 129 | end 130 | 131 | @doc ~S""" 132 | Creates a new spec. Specs can be created from any existing predicates or 133 | anonymous functions. Specs must return a boolean value. 134 | 135 | Predicates can be arbitrarily composed using the `and` and `or` keywords. 136 | 137 | ## Examples: 138 | 139 | iex> conform!(21, spec(is_integer())) 140 | 21 141 | iex> conform!(21, spec(is_integer() and &(&1 >= 21))) 142 | 21 143 | iex> conform("21", spec(is_integer() and &(&1 >= 21))) 144 | {:error, [%{spec: "is_integer()", input: "21", path: []}]} 145 | iex> conform!(:foo, spec(is_atom() or is_binary())) 146 | :foo 147 | iex> conform!("foo", spec(is_atom() or is_binary())) 148 | "foo" 149 | iex> conform(21, spec(is_atom() or is_binary())) 150 | {:error, [%{spec: "is_atom()", input: 21, path: []}, %{spec: "is_binary()", input: 21, path: []}]} 151 | """ 152 | defmacro spec(predicate) do 153 | Spec.build(predicate) 154 | end 155 | 156 | @doc ~S""" 157 | Allows encapsulation of a spec in another function. This enables late-binding of 158 | specs which enables definition of recursive specs. 159 | 160 | ## Examples: 161 | iex> conform!(%{"value" => 1, "left" => %{"value" => 2, "right" => %{"value" => 4}}}, Norm.Core.DelegateTest.TreeTest.spec()) 162 | %{"value" => 1, "left" => %{"value" => 2, "right" => %{"value" => 4}}} 163 | iex> conform(%{"value" => 1, "left" => %{"value" => 2, "right" => %{"value" => 4, "right" => %{"value" => "12"}}}}, Norm.Core.DelegateTest.TreeTest.spec()) 164 | {:error, [%{input: "12", path: ["left", "right", "right", "value"], spec: "is_integer()"}]} 165 | """ 166 | def delegate(predicate) do 167 | Delegate.build(predicate) 168 | end 169 | 170 | @doc ~S""" 171 | Creates a re-usable schema. Schema's are open which means that all keys are 172 | optional and any non-specified keys are passed through without being conformed. 173 | If you need to mark keys as required instead of optional you can use `selection`. 174 | 175 | ## Examples 176 | 177 | iex> valid?(%{}, schema(%{name: spec(is_binary())})) 178 | true 179 | iex> valid?(%{name: "Chris"}, schema(%{name: spec(is_binary())})) 180 | true 181 | iex> valid?(%{name: "Chris", age: 31}, schema(%{name: spec(is_binary())})) 182 | true 183 | iex> valid?(%{age: 31}, schema(%{name: spec(is_binary())})) 184 | true 185 | iex> valid?(%{name: 123}, schema(%{name: spec(is_binary())})) 186 | false 187 | iex> conform!(%{}, schema(%{name: spec(is_binary())})) 188 | %{} 189 | iex> conform!(%{age: 31, name: "chris"}, schema(%{name: spec(is_binary())})) 190 | %{age: 31, name: "chris"} 191 | iex> conform!(%{age: 31}, schema(%{name: spec(is_binary())})) 192 | %{age: 31} 193 | iex> conform!(%{user: %{name: "chris"}}, schema(%{user: schema(%{name: spec(is_binary())})})) 194 | %{user: %{name: "chris"}} 195 | """ 196 | def schema(input) when is_map(input) do 197 | Schema.build(input) 198 | end 199 | 200 | @doc ~S""" 201 | Selections can be used to mark keys on a schema as required. Any unspecified keys 202 | in the selection are still considered optional. Selections, like schemas, 203 | are open and allow unspecied keys to be passed through. If no selectors are 204 | provided then `selection` defaults to `:all` and recursively marks all keys in 205 | all nested schema's. If the schema includes internal selections these selections 206 | will not be overwritten. 207 | 208 | ## Examples 209 | 210 | iex> valid?(%{name: "chris"}, selection(schema(%{name: spec(is_binary())}), [:name])) 211 | true 212 | iex> valid?(%{}, selection(schema(%{name: spec(is_binary())}), [:name])) 213 | false 214 | iex> valid?(%{user: %{name: "chris"}}, selection(schema(%{user: schema(%{name: spec(is_binary())})}), [user: [:name]])) 215 | true 216 | iex> conform!(%{name: "chris"}, selection(schema(%{name: spec(is_binary())}), [:name])) 217 | %{name: "chris"} 218 | iex> conform!(%{name: "chris", age: 31}, selection(schema(%{name: spec(is_binary())}), [:name])) 219 | %{name: "chris", age: 31} 220 | 221 | ## Require all keys 222 | iex> valid?(%{user: %{name: "chris"}}, selection(schema(%{user: schema(%{name: spec(is_binary())})}))) 223 | true 224 | """ 225 | def selection(%Schema{} = schema, path \\ :all) do 226 | Selection.new(schema, path) 227 | end 228 | 229 | @doc ~S""" 230 | Chooses between alternative predicates or patterns. The patterns must be tagged with an atom. 231 | When conforming data to this specification the data is returned as a tuple with the tag. 232 | 233 | ## Examples 234 | 235 | iex> conform!("foo", alt(s: spec(is_binary()), a: spec(is_atom()))) 236 | {:s, "foo"} 237 | iex> conform!(:foo, alt(s: spec(is_binary()), a: spec(is_atom()))) 238 | {:a, :foo} 239 | iex> conform!(123, alt(num: spec(is_integer()), str: spec(is_binary()))) 240 | {:num, 123} 241 | iex> conform!("foo", alt(num: spec(is_integer()), str: spec(is_binary()))) 242 | {:str, "foo"} 243 | iex> conform(true, alt(num: spec(is_integer()), str: spec(is_binary()))) 244 | {:error, [%{spec: "is_integer()", input: true, path: [:num]}, %{spec: "is_binary()", input: true, path: [:str]}]} 245 | """ 246 | def alt(specs) when is_list(specs) do 247 | %Alt{specs: specs} 248 | end 249 | 250 | @doc """ 251 | Chooses between a list of options. Unlike `alt/1` the options don't need to 252 | be tagged. Specs are always tested in order and will short circuit if the 253 | data passes a validation. 254 | 255 | ## Examples 256 | iex> conform!("chris", one_of([spec(is_binary()), :alice])) 257 | "chris" 258 | iex> conform!(:alice, one_of([spec(is_binary()), :alice])) 259 | :alice 260 | """ 261 | def one_of(specs) when is_list(specs) do 262 | AnyOf.new(specs) 263 | end 264 | 265 | @doc ~S""" 266 | Specifies a generic collection. Collections can be any enumerable type. 267 | 268 | `coll_of` takes multiple arguments: 269 | 270 | * `:kind` - predicate function the kind of collection being conformed 271 | * `:distinct` - boolean value for specifying if the collection should have distinct elements 272 | * `:min_count` - Minimum element count 273 | * `:max_count` - Maximum element count 274 | * `:into` - The output collection the input will be conformed into. If not specified then the input type will be used. 275 | 276 | ## Examples 277 | 278 | iex> conform!([:a, :b, :c], coll_of(spec(is_atom()))) 279 | [:a, :b, :c] 280 | iex> conform!([:a, :b, :c], coll_of(spec(is_atom), into: MapSet.new())) 281 | MapSet.new([:a, :b, :c]) 282 | iex> conform!(MapSet.new([:a, :b, :c]), coll_of(spec(is_atom))) 283 | MapSet.new([:a, :b, :c]) 284 | iex> conform!(%{a: 1, b: 2, c: 3}, coll_of({spec(is_atom), spec(is_integer)})) 285 | %{a: 1, b: 2, c: 3} 286 | iex> conform!([1, 2], coll_of(spec(is_integer), min_count: 1)) 287 | [1, 2] 288 | """ 289 | @default_opts [ 290 | kind: nil, 291 | distinct: false, 292 | min_count: 0, 293 | max_count: :infinity, 294 | into: nil, 295 | ] 296 | 297 | def coll_of(spec, opts \\ []) do 298 | opts = Keyword.merge(@default_opts, opts) 299 | if opts[:min_count] > opts[:max_count] do 300 | raise ArgumentError, "min_count cannot be larger than max_count" 301 | end 302 | 303 | Collection.new(spec, opts) 304 | end 305 | 306 | @doc ~S""" 307 | Specifies a map with a type of key and a type of value. 308 | 309 | ## Examples 310 | 311 | iex> conform!(%{a: 1, b: 2, c: 3}, map_of(spec(is_atom()), spec(is_integer()))) 312 | %{a: 1, b: 2, c: 3} 313 | """ 314 | def map_of(kpred, vpred, opts \\ []) do 315 | opts = Keyword.merge(opts, [into: %{}, kind: &is_map/1]) 316 | coll_of({kpred, vpred}, opts) 317 | end 318 | 319 | # @doc ~S""" 320 | # Concatenates a sequence of predicates or patterns together. These predicates 321 | # must be tagged with an atom. The conformed data is returned as a 322 | # keyword list. 323 | 324 | # iex> conform!([31, "Chris"], cat(age: integer?(), name: string?())) 325 | # [age: 31, name: "Chris"] 326 | # iex> conform([true, "Chris"], cat(age: integer?(), name: string?())) 327 | # {:error, ["in: [0] at: :age val: true spec: integer?()"]} 328 | # iex> conform([31, :chris], cat(age: integer?(), name: string?())) 329 | # {:error, ["in: [1] at: :name val: :chris spec: string?()"]} 330 | # iex> conform([31], cat(age: integer?(), name: string?())) 331 | # {:error, ["in: [1] at: :name val: nil spec: Insufficient input"]} 332 | # """ 333 | # def cat(opts) do 334 | # fn path, input -> 335 | # results = 336 | # opts 337 | # |> Enum.with_index 338 | # |> Enum.map(fn {{tag, spec}, i} -> 339 | # val = Enum.at(input, i) 340 | # if val do 341 | # {tag, spec.(path ++ [{:index, i}], val)} 342 | # else 343 | # {tag, {:error, [error(path ++ [{:index, i}], nil, "Insufficient input")]}} 344 | # end 345 | # end) 346 | 347 | # errors = 348 | # results 349 | # |> Enum.filter(fn {_, {result, _}} -> result == :error end) 350 | # |> Enum.map(fn {tag, {_, errors}} -> {tag, errors} end) 351 | # |> Enum.flat_map(fn {tag, errors} -> Enum.map(errors, &(%{&1 | at: tag})) end) 352 | 353 | # if Enum.any?(errors) do 354 | # {:error, errors} 355 | # else 356 | # {:ok, Enum.map(results, fn {tag, {_, data}} -> {tag, data} end)} 357 | # end 358 | # end 359 | # end 360 | end 361 | -------------------------------------------------------------------------------- /lib/norm/conformer.ex: -------------------------------------------------------------------------------- 1 | defmodule Norm.Conformer do 2 | @moduledoc false 3 | # This module provides an api for conforming values and a protocol for 4 | # conformable types 5 | 6 | def conform(spec, input) do 7 | Norm.Conformer.Conformable.conform(spec, input, []) 8 | end 9 | 10 | def group_results(results) do 11 | results 12 | |> Enum.reduce(%{ok: [], error: []}, fn {result, s}, acc -> 13 | Map.put(acc, result, acc[result] ++ [s]) 14 | end) 15 | |> update_in([:error], &List.flatten(&1)) 16 | end 17 | 18 | def error(path, input, msg) do 19 | %{path: path, input: input, spec: msg} 20 | end 21 | 22 | def error_to_msg(%{path: path, input: input, spec: msg}) do 23 | path = if path == [], do: nil, else: "in: " <> build_path(path) 24 | val = "val: #{format_val(input)}" 25 | fails = "fails: #{msg}" 26 | 27 | [val, path, fails] 28 | |> Enum.reject(&is_nil/1) 29 | |> Enum.join(" ") 30 | end 31 | 32 | defp build_path(keys) do 33 | Enum.map_join(keys, "/", &format_val/1) 34 | end 35 | 36 | defp format_val(nil), do: "nil" 37 | defp format_val(msg) when is_binary(msg), do: "\"#{msg}\"" 38 | defp format_val(msg) when is_boolean(msg), do: "#{msg}" 39 | defp format_val(msg) when is_atom(msg), do: ":#{msg}" 40 | defp format_val(val) when is_map(val), do: inspect(val) 41 | defp format_val({:index, i}), do: "[#{i}]" 42 | defp format_val(t) when is_tuple(t), do: "#{inspect(t)}" 43 | defp format_val(l) when is_list(l), do: "#{inspect(l)}" 44 | 45 | defp format_val(msg), do: inspect(msg) 46 | 47 | defprotocol Conformable do 48 | @moduledoc false 49 | # Defines a conformable type. Must take the type, current path, and input and 50 | # return an success tuple with the conformed data or a list of errors. 51 | 52 | # @fallback_to_any true 53 | def conform(spec, path, input) 54 | end 55 | end 56 | 57 | # defimpl Norm.Conformer.Conformable, for: Any do 58 | # def conform(_thing, input, _path) do 59 | # {:ok, input} 60 | # end 61 | # end 62 | 63 | defimpl Norm.Conformer.Conformable, for: Atom do 64 | alias Norm.Conformer 65 | 66 | def conform(atom, input, path) do 67 | cond do 68 | not is_atom(input) -> 69 | {:error, [Conformer.error(path, input, "is not an atom.")]} 70 | 71 | atom != input -> 72 | {:error, [Conformer.error(path, input, "== :#{atom}")]} 73 | 74 | true -> 75 | {:ok, atom} 76 | end 77 | end 78 | end 79 | 80 | defimpl Norm.Conformer.Conformable, for: Tuple do 81 | alias Norm.Conformer 82 | alias Norm.Conformer.Conformable 83 | 84 | def conform(spec, input, path) when is_tuple(input) and tuple_size(spec) != tuple_size(input) do 85 | {:error, [Conformer.error(path, input, "incorrect tuple size")]} 86 | end 87 | 88 | def conform(_spec, input, path) when not is_tuple(input) do 89 | {:error, [Conformer.error(path, input, "not a tuple")]} 90 | end 91 | 92 | def conform(spec, input, path) do 93 | results = 94 | spec 95 | |> Tuple.to_list() 96 | |> Enum.with_index() 97 | |> Enum.map(fn {spec, i} -> Conformable.conform(spec, elem(input, i), path ++ [i]) end) 98 | |> Conformer.group_results() 99 | 100 | if Enum.any?(results.error) do 101 | {:error, results.error} 102 | else 103 | {:ok, List.to_tuple(results.ok)} 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/norm/contract.ex: -------------------------------------------------------------------------------- 1 | defmodule Norm.Contract do 2 | @moduledoc """ 3 | Design by Contract with Norm. 4 | 5 | This module provides a `@contract` macro that can be used to define specs for arguments and the 6 | return value of a given function. 7 | 8 | To use contracts, call `use Norm` which also imports all `Norm` functions. 9 | 10 | Sometimes you may want to turn off contracts checking. For example, to skip contracts in production, 11 | set: `config :norm, enable_contracts: Mix.env != :prod`. 12 | 13 | ## Examples 14 | 15 | defmodule Colors do 16 | use Norm 17 | 18 | def rgb(), do: spec(is_integer() and &(&1 in 0..255)) 19 | 20 | def hex(), do: spec(is_binary() and &String.starts_with?(&1, "#")) 21 | 22 | @contract rgb_to_hex(r :: rgb(), g :: rgb(), b :: rgb()) :: hex() 23 | def rgb_to_hex(r, g, b) do 24 | # ... 25 | end 26 | end 27 | 28 | """ 29 | 30 | defstruct [:args, :result] 31 | 32 | @doc false 33 | defmacro __using__(_) do 34 | quote do 35 | import Kernel, except: [@: 1] 36 | Module.register_attribute(__MODULE__, :norm_contracts, accumulate: true) 37 | @before_compile Norm.Contract 38 | import Norm.Contract 39 | end 40 | end 41 | 42 | @doc false 43 | defmacro __before_compile__(env) do 44 | definitions = Module.definitions_in(env.module) 45 | contracts = Module.get_attribute(env.module, :norm_contracts) 46 | 47 | for {name, arity, line} <- contracts do 48 | unless {name, arity} in definitions do 49 | raise ArgumentError, "contract for undefined function #{name}/#{arity}" 50 | end 51 | 52 | defconformer(name, arity, line) 53 | end 54 | end 55 | 56 | @doc false 57 | defmacro @{:contract, _, expr} do 58 | defcontract(expr, __CALLER__.line) 59 | end 60 | 61 | defmacro @other do 62 | quote do 63 | Kernel.@(unquote(other)) 64 | end 65 | end 66 | 67 | defp defconformer(name, arity, line) do 68 | args = Macro.generate_arguments(arity, nil) 69 | 70 | quote line: line do 71 | defoverridable [{unquote(name), unquote(arity)}] 72 | 73 | def unquote(name)(unquote_splicing(args)) do 74 | contract = __MODULE__.__contract__({unquote(name), unquote(arity)}) 75 | 76 | for {value, {_name, spec}} <- Enum.zip(unquote(args), contract.args) do 77 | Norm.conform!(value, spec) 78 | end 79 | 80 | result = super(unquote_splicing(args)) 81 | Norm.conform!(result, contract.result) 82 | end 83 | end 84 | end 85 | 86 | defp defcontract(expr, line) do 87 | if Application.get_env(:norm, :enable_contracts, true) do 88 | {name, args, result} = parse_contract_expr(expr) 89 | arity = length(args) 90 | 91 | quote do 92 | @doc false 93 | def __contract__({unquote(name), unquote(arity)}) do 94 | %Norm.Contract{args: unquote(args), result: unquote(result)} 95 | end 96 | 97 | @norm_contracts {unquote(name), unquote(arity), unquote(line)} 98 | end 99 | end 100 | end 101 | 102 | defp parse_contract_expr([{:"::", _, [{name, _, args}, result]}]) do 103 | args = args |> Enum.with_index(1) |> Enum.map(&parse_arg/1) 104 | {name, args, result} 105 | end 106 | 107 | defp parse_contract_expr(expr) do 108 | actual = Macro.to_string({:@, [], [{:contract, [], expr}]}) 109 | 110 | raise ArgumentError, 111 | "contract must be in the form " <> 112 | "`@contract function(arg1, arg2) :: spec`, got: `#{actual}`" 113 | end 114 | 115 | defp parse_arg({{:"::", _, [{name, _, _}, spec]}, _index}) do 116 | {name, spec} 117 | end 118 | 119 | defp parse_arg({spec, index}) do 120 | {:"arg#{index}", spec} 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/norm/core/all_of.ex: -------------------------------------------------------------------------------- 1 | defmodule Norm.Core.AllOf do 2 | @moduledoc false 3 | 4 | defstruct specs: [] 5 | 6 | def new(specs) do 7 | %__MODULE__{specs: specs} 8 | end 9 | 10 | defimpl Norm.Conformer.Conformable do 11 | alias Norm.Conformer 12 | alias Norm.Conformer.Conformable 13 | 14 | def conform(%{specs: specs}, input, path) do 15 | result = 16 | specs 17 | |> Enum.map(fn spec -> Conformable.conform(spec, input, path) end) 18 | |> Conformer.group_results() 19 | 20 | if result.error != [] do 21 | {:error, List.flatten(result.error)} 22 | else 23 | {:ok, Enum.at(result.ok, 0)} 24 | end 25 | end 26 | end 27 | end 28 | 29 | -------------------------------------------------------------------------------- /lib/norm/core/alt.ex: -------------------------------------------------------------------------------- 1 | defmodule Norm.Core.Alt do 2 | @moduledoc false 3 | 4 | defstruct specs: [] 5 | 6 | defimpl Norm.Conformer.Conformable do 7 | alias Norm.Conformer 8 | alias Norm.Conformer.Conformable 9 | 10 | def conform(%{specs: specs}, input, path) do 11 | result = 12 | specs 13 | |> Enum.map(fn {name, spec} -> 14 | case Conformable.conform(spec, input, path ++ [name]) do 15 | {:ok, i} -> 16 | {:ok, {name, i}} 17 | 18 | {:error, errors} -> 19 | {:error, errors} 20 | end 21 | end) 22 | |> Conformer.group_results() 23 | 24 | if Enum.any?(result.ok) do 25 | {:ok, Enum.at(result.ok, 0)} 26 | else 27 | {:error, List.flatten(result.error)} 28 | end 29 | end 30 | end 31 | 32 | if Code.ensure_loaded?(StreamData) do 33 | defimpl Norm.Generatable do 34 | def gen(%{specs: specs}) do 35 | case Enum.reduce(specs, [], &to_gen/2) do 36 | {:error, error} -> 37 | {:error, error} 38 | 39 | generators -> 40 | {:ok, StreamData.one_of(generators)} 41 | end 42 | end 43 | 44 | def to_gen(_, {:error, error}), do: {:error, error} 45 | 46 | def to_gen({_key, spec}, generators) do 47 | case Norm.Generatable.gen(spec) do 48 | {:ok, g} -> 49 | [g | generators] 50 | 51 | {:error, error} -> 52 | {:error, error} 53 | end 54 | end 55 | end 56 | end 57 | 58 | defimpl Inspect do 59 | import Inspect.Algebra 60 | 61 | def inspect(alt, opts) do 62 | concat(["#Norm.Alt<", to_doc(alt.specs, opts), ">"]) 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/norm/core/any_of.ex: -------------------------------------------------------------------------------- 1 | defmodule Norm.Core.AnyOf do 2 | @moduledoc false 3 | # Provides the struct for unions of specifications 4 | 5 | defstruct specs: [] 6 | 7 | def new(specs) do 8 | %__MODULE__{specs: specs} 9 | end 10 | 11 | defimpl Norm.Conformer.Conformable do 12 | alias Norm.Conformer 13 | alias Norm.Conformer.Conformable 14 | 15 | def conform(%{specs: specs}, input, path) do 16 | result = 17 | specs 18 | |> Enum.map(fn spec -> Conformable.conform(spec, input, path) end) 19 | |> Conformer.group_results() 20 | 21 | if result.ok != [] do 22 | {:ok, Enum.at(result.ok, 0)} 23 | else 24 | {:error, List.flatten(result.error)} 25 | end 26 | end 27 | end 28 | 29 | if Code.ensure_loaded?(StreamData) do 30 | defimpl Norm.Generatable do 31 | def gen(%{specs: specs}) do 32 | case Enum.reduce(specs, [], &to_gen/2) do 33 | {:error, error} -> 34 | {:error, error} 35 | 36 | generators -> 37 | {:ok, StreamData.one_of(generators)} 38 | end 39 | end 40 | 41 | def to_gen(_, {:error, error}), do: {:error, error} 42 | 43 | def to_gen(spec, generators) do 44 | case Norm.Generatable.gen(spec) do 45 | {:ok, g} -> 46 | [g | generators] 47 | 48 | {:error, error} -> 49 | {:error, error} 50 | end 51 | end 52 | end 53 | end 54 | 55 | defimpl Inspect do 56 | import Inspect.Algebra 57 | 58 | def inspect(union, opts) do 59 | concat(["#Norm.OneOf<", to_doc(union.specs, opts), ">"]) 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/norm/core/collection.ex: -------------------------------------------------------------------------------- 1 | defmodule Norm.Core.Collection do 2 | @moduledoc false 3 | 4 | defstruct spec: nil, opts: [] 5 | 6 | def new(spec, opts) do 7 | %__MODULE__{spec: spec, opts: opts} 8 | end 9 | 10 | defimpl Norm.Conformer.Conformable do 11 | alias Norm.Conformer 12 | alias Norm.Conformer.Conformable 13 | 14 | def conform(%{spec: spec, opts: opts}, input, path) do 15 | with :ok <- check_enumerable(input, path, opts), 16 | :ok <- check_kind_of(input, path, opts), 17 | :ok <- check_distinct(input, path, opts), 18 | :ok <- check_counts(input, path, opts) do 19 | results = 20 | input 21 | |> Enum.with_index() 22 | |> Enum.map(fn {elem, i} -> Conformable.conform(spec, elem, path ++ [i]) end) 23 | |> Conformer.group_results() 24 | 25 | into = cond do 26 | opts[:into] -> 27 | opts[:into] 28 | 29 | is_list(input) -> 30 | [] 31 | 32 | is_map(input) and Map.has_key?(input, :__struct__) -> 33 | struct(input.__struct__) 34 | 35 | is_map(input) -> 36 | %{} 37 | 38 | true -> 39 | raise ArgumentError, "Cannot determine output type for collection" 40 | end 41 | 42 | if Enum.any?(results.error) do 43 | {:error, results.error} 44 | else 45 | {:ok, convert(results.ok, into)} 46 | end 47 | end 48 | end 49 | 50 | defp convert(results, type) do 51 | Enum.into(results, type) 52 | end 53 | 54 | defp check_counts(input, path, opts) do 55 | min = opts[:min_count] 56 | max = opts[:max_count] 57 | length = Enum.count(input) 58 | 59 | cond do 60 | min > length -> 61 | {:error, [Conformer.error(path, input, "min_count: #{min}")]} 62 | 63 | max < length -> 64 | {:error, [Conformer.error(path, input, "max_count: #{max}")]} 65 | 66 | true -> 67 | :ok 68 | end 69 | end 70 | 71 | defp check_distinct(input, path, opts) do 72 | if opts[:distinct] do 73 | if Enum.uniq(input) == input do 74 | :ok 75 | else 76 | {:error, [Conformer.error(path, input, "distinct?")]} 77 | end 78 | else 79 | :ok 80 | end 81 | end 82 | 83 | defp check_enumerable(input, path, _opts) do 84 | if Enumerable.impl_for(input) == nil do 85 | {:error, [Conformer.error(path, input, "not enumerable")]} 86 | else 87 | :ok 88 | end 89 | end 90 | 91 | defp check_kind_of(input, path, opts) do 92 | cond do 93 | # If kind is nil we assume it doesn't matter 94 | opts[:kind] == nil -> 95 | :ok 96 | 97 | # If we have a `:kind` and it returns true we pass the spec 98 | opts[:kind].(input) -> 99 | :ok 100 | 101 | # Otherwise return an error 102 | true -> 103 | {:error, [Conformer.error(path, input, "does not match kind: #{inspect opts[:kind]}")]} 104 | end 105 | end 106 | end 107 | 108 | if Code.ensure_loaded?(StreamData) do 109 | defimpl Norm.Generatable do 110 | def gen(%{spec: spec, opts: opts}) do 111 | with {:ok, g} <- Norm.Generatable.gen(spec) do 112 | generator = 113 | g 114 | |> sequence(opts) 115 | |> into(opts) 116 | 117 | {:ok, generator} 118 | end 119 | end 120 | 121 | def sequence(g, opts) do 122 | min = opts[:min_count] 123 | max = opts[:max_count] 124 | 125 | if opts[:distinct] do 126 | StreamData.uniq_list_of(g, [min_length: min, max_length: max]) 127 | else 128 | StreamData.list_of(g, [min_length: min, max_length: max]) 129 | end 130 | end 131 | 132 | def into(list_gen, opts) do 133 | StreamData.bind(list_gen, fn list -> 134 | # We assume that if we don't have an `into` specified then its a list 135 | StreamData.constant(Enum.into(list, opts[:into] || [])) 136 | end) 137 | end 138 | end 139 | end 140 | 141 | defimpl Inspect do 142 | import Inspect.Algebra 143 | 144 | def inspect(coll_of, opts) do 145 | concat(["#Norm.CollOf<", to_doc(coll_of.spec, opts), ">"]) 146 | end 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /lib/norm/core/delegate.ex: -------------------------------------------------------------------------------- 1 | defmodule Norm.Core.Delegate do 2 | @moduledoc false 3 | 4 | defstruct [:fun] 5 | 6 | def build(fun) when is_function(fun, 0) do 7 | %__MODULE__{fun: fun} 8 | end 9 | 10 | defimpl Norm.Conformer.Conformable do 11 | def conform(%{fun: fun}, input, path) do 12 | Norm.Conformer.Conformable.conform(fun.(), input, path) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/norm/core/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule Norm.Core.Schema do 2 | @moduledoc false 3 | # Provides the definition for schemas 4 | 5 | alias __MODULE__ 6 | 7 | defstruct specs: %{}, struct: nil 8 | 9 | def build(%{__struct__: name} = struct) do 10 | # If we're building a schema from a struct then we need to reject any keys with 11 | # values that don't implement the conformable protocol. This allows users to specify 12 | # struct types without needing to specify specs for each key 13 | specs = 14 | struct 15 | |> Map.from_struct() 16 | |> Enum.reject(fn {_, value} -> Norm.Conformer.Conformable.impl_for(value) == nil end) 17 | |> Enum.into(%{}) 18 | 19 | %Schema{specs: specs, struct: name} 20 | end 21 | 22 | def build(map) when is_map(map) do 23 | %Schema{specs: map} 24 | end 25 | 26 | def spec(schema, key) do 27 | schema.specs 28 | |> Enum.filter(fn {name, _} -> name == key end) 29 | |> Enum.map(fn {_, spec} -> spec end) 30 | |> Enum.at(0) 31 | end 32 | 33 | def key_present?(schema, key) do 34 | schema.specs 35 | |> Enum.any?(fn {name, _} -> name == key end) 36 | end 37 | 38 | defimpl Norm.Conformer.Conformable do 39 | alias Norm.Conformer 40 | alias Norm.Conformer.Conformable 41 | 42 | def conform(_, input, path) when not is_map(input) do 43 | {:error, [Conformer.error(path, input, "not a map")]} 44 | end 45 | 46 | # Conforming a struct 47 | def conform(%{specs: specs, struct: target}, input, path) when not is_nil(target) do 48 | # Ensure we're mapping the correct struct 49 | cond do 50 | Map.get(input, :__struct__) != target -> 51 | short_name = 52 | target 53 | |> Atom.to_string() 54 | |> String.replace("Elixir.", "") 55 | 56 | {:error, [Conformer.error(path, input, "#{short_name}")]} 57 | 58 | true -> 59 | with {:ok, conformed} <- check_specs(specs, Map.from_struct(input), path) do 60 | {:ok, struct(target, conformed)} 61 | end 62 | end 63 | end 64 | 65 | # conforming a map. 66 | def conform(%Schema{specs: specs}, input, path) do 67 | if Map.get(input, :__struct__) != nil do 68 | with {:ok, conformed} <- check_specs(specs, Map.from_struct(input), path) do 69 | {:ok, struct(input.__struct__, conformed)} 70 | end 71 | else 72 | check_specs(specs, input, path) 73 | end 74 | end 75 | 76 | defp check_specs(specs, input, path) do 77 | results = 78 | input 79 | |> Enum.map(&check_spec(&1, specs, path)) 80 | |> Enum.reduce(%{ok: [], error: []}, fn {key, {result, conformed}}, acc -> 81 | Map.put(acc, result, acc[result] ++ [{key, conformed}]) 82 | end) 83 | 84 | errors = 85 | results.error 86 | |> Enum.flat_map(fn {_, error} -> error end) 87 | 88 | if Enum.any?(errors) do 89 | {:error, errors} 90 | else 91 | {:ok, Enum.into(results.ok, %{})} 92 | end 93 | end 94 | 95 | defp check_spec({key, value}, specs, path) do 96 | case Map.get(specs, key) do 97 | nil -> 98 | {key, {:ok, value}} 99 | 100 | spec -> 101 | {key, Conformable.conform(spec, value, path ++ [key])} 102 | end 103 | end 104 | end 105 | 106 | if Code.ensure_loaded?(StreamData) do 107 | defimpl Norm.Generatable do 108 | alias Norm.Generatable 109 | 110 | def gen(%{struct: target, specs: specs}) do 111 | case Enum.reduce(specs, %{}, &to_gen/2) do 112 | {:error, error} -> 113 | {:error, error} 114 | 115 | generator -> 116 | to_streamdata(generator, target) 117 | end 118 | end 119 | 120 | defp to_streamdata(generator, nil) do 121 | {:ok, StreamData.fixed_map(generator)} 122 | end 123 | 124 | defp to_streamdata(generator, target) do 125 | sd = 126 | generator 127 | |> StreamData.fixed_map() 128 | |> StreamData.bind(fn map -> StreamData.constant(struct(target, map)) end) 129 | 130 | {:ok, sd} 131 | end 132 | 133 | def to_gen(_, {:error, error}), do: {:error, error} 134 | 135 | def to_gen({key, spec}, generator) do 136 | case Generatable.gen(spec) do 137 | {:ok, g} -> 138 | Map.put(generator, key, g) 139 | 140 | {:error, error} -> 141 | {:error, error} 142 | end 143 | end 144 | end 145 | end 146 | 147 | defimpl Inspect do 148 | import Inspect.Algebra 149 | 150 | def inspect(schema, opts) do 151 | map = if schema.struct do 152 | struct(schema.struct, schema.specs) 153 | else 154 | schema.specs 155 | end 156 | concat(["#Norm.Schema<", to_doc(map, opts), ">"]) 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /lib/norm/core/selection.ex: -------------------------------------------------------------------------------- 1 | defmodule Norm.Core.Selection do 2 | @moduledoc false 3 | # Provides the definition for selections 4 | 5 | defstruct required: [], schema: nil 6 | 7 | alias Norm.Core.Schema 8 | alias Norm.SpecError 9 | 10 | def new(schema, selectors) do 11 | # We're going to front load some work so that we can ensure that people are 12 | # requiring keys that actually exist in the schema and so that we can make 13 | # it easier to conform in the future. 14 | # select(schema, path, %{}) 15 | case selectors do 16 | :all -> 17 | selectors = build_all_selectors(schema) 18 | select(selectors, schema) 19 | 20 | _ -> 21 | validate_selectors!(selectors) 22 | select(selectors, schema) 23 | end 24 | end 25 | 26 | def select(selectors, schema, required \\ []) 27 | def select([], schema, required), do: %__MODULE__{schema: schema, required: required} 28 | def select([selector | rest], schema, required) do 29 | case selector do 30 | {key, inner_keys} -> 31 | inner_schema = assert_spec!(schema, key) 32 | selection = select(inner_keys, inner_schema) 33 | select(rest, schema, [{key, selection} | required]) 34 | 35 | key -> 36 | _ = assert_spec!(schema, key) 37 | select(rest, schema, [key | required]) 38 | end 39 | end 40 | 41 | defp build_all_selectors(schema) do 42 | schema.specs 43 | |> Enum.map(fn 44 | {name, %Schema{}=inner_schema} -> {name, build_all_selectors(inner_schema)} 45 | {name, _} -> name 46 | end) 47 | end 48 | 49 | defp validate_selectors!([]), do: true 50 | defp validate_selectors!([{_key, inner} | rest]), do: validate_selectors!(inner) and validate_selectors!(rest) 51 | defp validate_selectors!([_key | rest]), do: validate_selectors!(rest) 52 | defp validate_selectors!(other), do: raise(ArgumentError, "select expects a list of keys but received: #{inspect other}") 53 | 54 | defp assert_spec!(%Schema{}=schema, key) do 55 | case Schema.key_present?(schema, key) do 56 | false -> raise SpecError, {:selection, key, schema} 57 | true -> Schema.spec(schema, key) 58 | end 59 | end 60 | defp assert_spec!(%__MODULE__{}, _key) do 61 | # In the future we might support this and allow users to overwrite internal 62 | # selections. But for now its safer to forbid this. 63 | raise ArgumentError, """ 64 | Attempting to specify a selection on top of another selection is 65 | not allowed. 66 | """ 67 | end 68 | defp assert_spec!(other, _key) do 69 | raise ArgumentError, "Expected a schema and got: #{inspect other}" 70 | end 71 | 72 | defimpl Norm.Conformer.Conformable do 73 | alias Norm.Conformer 74 | alias Norm.Conformer.Conformable 75 | 76 | def conform(_, input, path) when not is_map(input) do 77 | {:error, [Conformer.error(path, input, "not a map")]} 78 | end 79 | 80 | def conform(%{required: required, schema: schema}, input, path) do 81 | case Conformable.conform(schema, input, path) do 82 | {:ok, conformed} -> 83 | errors = ensure_keys(required, conformed, path, []) 84 | if Enum.any?(errors) do 85 | {:error, errors} 86 | else 87 | {:ok, conformed} 88 | end 89 | 90 | {:error, conforming_errors} -> 91 | errors = ensure_keys(required, input, path, []) 92 | {:error, conforming_errors ++ errors} 93 | end 94 | end 95 | 96 | defp ensure_keys([], _conformed, _path, errors), do: errors 97 | defp ensure_keys([{key, inner} | rest], conformed, path, errors) do 98 | case ensure_key(key, conformed, path) do 99 | :ok -> 100 | inner_value = Map.get(conformed, key) 101 | inner_errors = ensure_keys(inner.required, inner_value, path ++ [key], []) 102 | ensure_keys(rest, conformed, path, errors ++ inner_errors) 103 | 104 | error -> 105 | ensure_keys(rest, conformed, path, [error | errors]) 106 | end 107 | end 108 | defp ensure_keys([key | rest], conformed, path, errors) do 109 | case ensure_key(key, conformed, path) do 110 | :ok -> 111 | ensure_keys(rest, conformed, path, errors) 112 | 113 | error -> 114 | ensure_keys(rest, conformed, path, [error | errors]) 115 | end 116 | end 117 | 118 | defp ensure_key(_key, conformed, _path) when not is_map(conformed), do: :ok 119 | defp ensure_key(key, conformed, path) do 120 | if Map.has_key?(conformed, key) do 121 | :ok 122 | else 123 | Conformer.error(path ++ [key], conformed, ":required") 124 | end 125 | end 126 | end 127 | 128 | if Code.ensure_loaded?(StreamData) do 129 | defimpl Norm.Generatable do 130 | alias Norm.Generatable 131 | 132 | # In order to build a semantically meaningful selection we need to generate 133 | # all of the specified fields as well as the fields from the underlying 134 | # schema. We can then merge both of those maps together with the required 135 | # fields taking precedence. 136 | def gen(%{required: required, schema: schema}) do 137 | case Enum.reduce(required, %{}, & to_gen(&1, schema, &2)) do 138 | {:error, error} -> 139 | {:error, error} 140 | 141 | gen -> 142 | {:ok, StreamData.fixed_map(gen)} 143 | end 144 | end 145 | 146 | defp to_gen(_, _schema, {:error, error}), do: {:error, error} 147 | # If we're here than we're processing a key with an inner selection. 148 | defp to_gen({key, selection}, _schema, generator) do 149 | case Generatable.gen(selection) do 150 | {:ok, g} -> 151 | Map.put(generator, key, g) 152 | 153 | {:error, error} -> 154 | {:error, error} 155 | end 156 | end 157 | defp to_gen(key, schema, generator) do 158 | # Its safe to just get the spec because at this point we *know* that the 159 | # keys that have been selected are in the schema. 160 | with {:ok, g} <- Generatable.gen(Norm.Core.Schema.spec(schema, key)) do 161 | Map.put(generator, key, g) 162 | end 163 | end 164 | end 165 | end 166 | 167 | defimpl Inspect do 168 | import Inspect.Algebra 169 | 170 | def inspect(selection, opts) do 171 | map = %{ 172 | schema: selection.schema, 173 | required: selection.required 174 | } 175 | concat(["#Norm.Selection<", to_doc(map, opts), ">"]) 176 | end 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /lib/norm/core/spec.ex: -------------------------------------------------------------------------------- 1 | defmodule Norm.Core.Spec do 2 | @moduledoc false 3 | # Provides a struct to encapsulate specs 4 | 5 | alias __MODULE__ 6 | 7 | alias Norm.Core.Spec.{ 8 | And, 9 | Or 10 | } 11 | 12 | defstruct predicate: nil, generator: nil, f: nil 13 | 14 | def build({:or, _, [left, right]}) do 15 | l = build(left) 16 | r = build(right) 17 | 18 | quote do 19 | %Or{left: unquote(l), right: unquote(r)} 20 | end 21 | end 22 | 23 | def build({:and, _, [left, right]}) do 24 | l = build(left) 25 | r = build(right) 26 | 27 | quote do 28 | And.new(unquote(l), unquote(r)) 29 | end 30 | end 31 | 32 | # Anonymous functions 33 | def build(quoted = {f, _, _args}) when f in [:&, :fn] do 34 | predicate = Macro.to_string(quoted) 35 | 36 | quote do 37 | run = fn input -> 38 | input |> unquote(quoted).() 39 | end 40 | 41 | %Spec{generator: nil, predicate: unquote(predicate), f: run} 42 | end 43 | end 44 | 45 | # Standard functions 46 | def build(quoted = {a, _, args}) when is_atom(a) and is_list(args) do 47 | predicate = Macro.to_string(quoted) 48 | 49 | quote do 50 | run = fn input -> 51 | input |> unquote(quoted) 52 | end 53 | 54 | %Spec{predicate: unquote(predicate), f: run, generator: unquote(a)} 55 | end 56 | end 57 | 58 | # Function without parens 59 | def build(quoted = {a, _, _}) when is_atom(a) do 60 | predicate = Macro.to_string(quoted) <> "()" 61 | 62 | quote do 63 | run = fn input -> 64 | input |> unquote(quoted) 65 | end 66 | 67 | %Spec{predicate: unquote(predicate), f: run, generator: unquote(a)} 68 | end 69 | end 70 | 71 | # Guard-safe functions in the Integer module. 72 | def build({{:., _, [{_, _, [:Integer]}, guard_name]}, _, _} = quoted) 73 | when guard_name in [:is_even, :is_odd] do 74 | predicate = Macro.to_string(quoted) 75 | 76 | quote do 77 | run = fn input -> 78 | input |> unquote(quoted) 79 | end 80 | 81 | %Spec{predicate: unquote(predicate), f: run, generator: unquote(guard_name)} 82 | end 83 | end 84 | 85 | # Remote call 86 | def build({{:., _, _}, _, _} = quoted) do 87 | predicate = Macro.to_string(quoted) 88 | 89 | quote do 90 | run = fn input -> 91 | input |> unquote(quoted) 92 | end 93 | 94 | %Spec{predicate: unquote(predicate), f: run, generator: :none} 95 | end 96 | end 97 | 98 | def build(quoted) do 99 | spec = Macro.to_string(quoted) 100 | 101 | raise ArgumentError, "Norm can't build a spec from: #{spec}" 102 | end 103 | 104 | if Code.ensure_loaded?(StreamData) do 105 | defimpl Norm.Generatable do 106 | def gen(%{generator: gen, predicate: pred}) do 107 | case build_generator(gen) do 108 | nil -> {:error, pred} 109 | generator -> {:ok, generator} 110 | end 111 | end 112 | 113 | defp build_generator(gen) do 114 | case gen do 115 | :is_atom -> StreamData.atom(:alphanumeric) 116 | :is_binary -> StreamData.binary() 117 | :is_bitstring -> StreamData.bitstring() 118 | :is_boolean -> StreamData.boolean() 119 | :is_float -> StreamData.float() 120 | :is_integer -> StreamData.integer() 121 | :is_list -> StreamData.list_of(StreamData.term()) 122 | :is_tuple -> StreamData.list_of(StreamData.term()) |> StreamData.map(&List.to_tuple/1) 123 | :is_nil -> StreamData.constant(nil) 124 | :is_number -> StreamData.one_of([StreamData.integer(), StreamData.float()]) 125 | :is_even -> StreamData.integer() |> StreamData.map(&(&1 * 2)) 126 | :is_odd -> StreamData.integer() |> StreamData.map(&(&1 * 2 + 1)) 127 | _ -> nil 128 | end 129 | end 130 | end 131 | end 132 | 133 | defimpl Norm.Conformer.Conformable do 134 | def conform(%{f: f, predicate: pred}, input, path) do 135 | case f.(input) do 136 | true -> 137 | {:ok, input} 138 | 139 | false -> 140 | {:error, [Norm.Conformer.error(path, input, pred)]} 141 | 142 | _ -> 143 | raise ArgumentError, "Predicates must return a boolean value" 144 | end 145 | end 146 | end 147 | 148 | @doc false 149 | def __inspect__(spec) do 150 | spec.predicate 151 | end 152 | 153 | defimpl Inspect do 154 | def inspect(spec, _) do 155 | Inspect.Algebra.concat(["#Norm.Spec<", spec.predicate, ">"]) 156 | end 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /lib/norm/core/spec/and.ex: -------------------------------------------------------------------------------- 1 | defmodule Norm.Core.Spec.And do 2 | @moduledoc false 3 | 4 | alias Norm.Core.Spec 5 | alias __MODULE__ 6 | 7 | defstruct [:left, :right] 8 | 9 | def new(l, r) do 10 | case {l, r} do 11 | {%Spec{}, %Spec{}} -> 12 | %__MODULE__{left: l, right: r} 13 | 14 | {%And{}, %Spec{}} -> 15 | %__MODULE__{left: l, right: r} 16 | 17 | _ -> 18 | raise ArgumentError, "both sides of an `and` must be a predicate" 19 | end 20 | end 21 | 22 | defimpl Norm.Conformer.Conformable do 23 | alias Norm.Conformer.Conformable 24 | 25 | def conform(%{left: l, right: r}, input, path) do 26 | with {:ok, _} <- Conformable.conform(l, input, path) do 27 | Conformable.conform(r, input, path) 28 | end 29 | end 30 | end 31 | 32 | if Code.ensure_loaded?(StreamData) do 33 | defimpl Norm.Generatable do 34 | alias Norm.Generatable 35 | 36 | def gen(%{left: l, right: r}) do 37 | with {:ok, gen} <- Generatable.gen(l) do 38 | {:ok, StreamData.filter(gen, r.f)} 39 | end 40 | end 41 | end 42 | end 43 | 44 | @doc false 45 | def __inspect__(%{left: left, right: right}) do 46 | left = left.__struct__.__inspect__(left) 47 | right = right.__struct__.__inspect__(right) 48 | Inspect.Algebra.concat([left, " and ", right]) 49 | end 50 | 51 | defimpl Inspect do 52 | def inspect(struct, _) do 53 | Inspect.Algebra.concat(["#Norm.Spec<", @for.__inspect__(struct), ">"]) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/norm/core/spec/or.ex: -------------------------------------------------------------------------------- 1 | defmodule Norm.Core.Spec.Or do 2 | @moduledoc false 3 | 4 | defstruct [:left, :right] 5 | 6 | defimpl Norm.Conformer.Conformable do 7 | alias Norm.Conformer.Conformable, as: Conform 8 | 9 | def conform(%{left: l, right: r}, input, path) do 10 | case Conform.conform(l, input, path) do 11 | {:ok, input} -> 12 | {:ok, input} 13 | 14 | {:error, l_errors} -> 15 | case Conform.conform(r, input, path) do 16 | {:ok, input} -> 17 | {:ok, input} 18 | 19 | {:error, r_errors} -> 20 | {:error, l_errors ++ r_errors} 21 | end 22 | end 23 | end 24 | end 25 | 26 | if Code.ensure_loaded?(StreamData) do 27 | defimpl Norm.Generatable do 28 | alias Norm.Generatable 29 | 30 | def gen(%{left: l, right: r}) do 31 | with {:ok, l} <- Generatable.gen(l), 32 | {:ok, r} <- Generatable.gen(r) do 33 | {:ok, StreamData.one_of([l, r])} 34 | end 35 | end 36 | end 37 | end 38 | 39 | @doc false 40 | def __inspect__(%{left: left, right: right}) do 41 | left = left.__struct__.__inspect__(left) 42 | right = right.__struct__.__inspect__(right) 43 | Inspect.Algebra.concat([left, " or ", right]) 44 | end 45 | 46 | defimpl Inspect do 47 | def inspect(struct, _) do 48 | Inspect.Algebra.concat(["#Norm.Spec<", @for.__inspect__(struct), ">"]) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/norm/errors.ex: -------------------------------------------------------------------------------- 1 | defmodule Norm.MismatchError do 2 | defexception [:message] 3 | 4 | def exception(errors) do 5 | msg = Enum.map_join(errors, "\n", &Norm.Conformer.error_to_msg/1) 6 | 7 | %__MODULE__{message: "Could not conform input:\n" <> msg} 8 | end 9 | end 10 | 11 | defmodule Norm.GeneratorLibraryError do 12 | defexception [:message] 13 | 14 | def exception(_) do 15 | %__MODULE__{ 16 | message: "In order to use generators please include `stream_data` as a dependency" 17 | } 18 | end 19 | end 20 | 21 | defmodule Norm.GeneratorError do 22 | defexception [:message] 23 | 24 | def exception(predicate) do 25 | msg = "Unable to create a generator for: #{predicate}" 26 | %__MODULE__{message: msg} 27 | end 28 | end 29 | 30 | defmodule Norm.SpecError do 31 | defexception [:message] 32 | alias Norm.Core.Spec 33 | alias Norm.Core.Schema 34 | alias Norm.Core.Collection 35 | alias Norm.Core.Alt 36 | alias Norm.Core.AnyOf 37 | 38 | def exception(details) do 39 | %__MODULE__{message: msg(details)} 40 | end 41 | 42 | defp msg({:selection, key, schema}) do 43 | """ 44 | key: #{format(key)} was not found in schema: 45 | #{format(schema)} 46 | """ 47 | end 48 | 49 | defp format(val, indentation \\ 0) 50 | 51 | defp format({key, spec_or_schema}, i) do 52 | "{" <> format(key, i) <> ", " <> format(spec_or_schema, i + 1) <> "}" 53 | end 54 | defp format(atom, _) when is_atom(atom), do: ":#{atom}" 55 | defp format(str, _) when is_binary(str), do: ~s|"#{str}"| 56 | defp format(%Spec{}=s, _), do: inspect(s) 57 | defp format(%Spec.And{}=s, _), do: inspect(s) 58 | defp format(%Spec.Or{}=s, _), do: inspect(s) 59 | defp format(%Schema{specs: specs}, i) do 60 | f = fn {key, spec_or_schema}, i -> 61 | format(key, i) <> " => " <> format(spec_or_schema, i + 1) 62 | end 63 | 64 | specs = 65 | specs 66 | |> Enum.map(& f.(&1, i)) 67 | |> Enum.map_join("\n", &pad(&1, (i + 1) * 2)) 68 | 69 | "%{\n" <> specs <> "\n" <> pad("}", i * 2) 70 | end 71 | defp format(%Collection{spec: spec}, i) do 72 | "coll_of(#{format(spec, i)})" 73 | end 74 | defp format(%Alt{specs: specs}, i) do 75 | formatted = 76 | specs 77 | |> Enum.map(&format(&1, i)) 78 | |> Enum.map_join("\n", &pad(&1, (i + 1) * 2)) 79 | 80 | if length(specs) > 0 do 81 | "alt([\n#{formatted}\n" <> pad("])", i * 2) 82 | else 83 | "alt([])" 84 | end 85 | end 86 | defp format(%AnyOf{specs: specs}, i) do 87 | formatted = 88 | specs 89 | |> Enum.map(&format(&1, i)) 90 | |> Enum.map_join("\n", &pad(&1, (i + 1) * 2)) 91 | 92 | if length(specs) > 0 do 93 | "one_of([\n#{formatted}\n" <> pad("])", i * 2) 94 | else 95 | "one_of([])" 96 | end 97 | end 98 | defp format(val, _i) do 99 | inspect(val) 100 | end 101 | 102 | defp pad(str, 0), do: str 103 | defp pad(str, i), do: " " <> pad(str, i - 1) 104 | end 105 | -------------------------------------------------------------------------------- /lib/norm/generatable.ex: -------------------------------------------------------------------------------- 1 | defprotocol Norm.Generatable do 2 | @moduledoc false 3 | # Defines generatable types 4 | 5 | def gen(able) 6 | end 7 | 8 | if Code.ensure_loaded?(StreamData) do 9 | defimpl Norm.Generatable, for: Atom do 10 | def gen(atom) do 11 | {:ok, StreamData.constant(atom)} 12 | end 13 | end 14 | 15 | defimpl Norm.Generatable, for: Tuple do 16 | alias Norm.Generatable 17 | 18 | def gen(tuple) do 19 | elems = Tuple.to_list(tuple) 20 | 21 | with list when is_list(list) <- Enum.reduce(elems, [], &to_gen/2) do 22 | # The list we build is in reverse order so we need to reverse first 23 | generator = 24 | list 25 | |> Enum.reverse() 26 | |> List.to_tuple() 27 | |> StreamData.tuple() 28 | 29 | {:ok, generator} 30 | end 31 | end 32 | 33 | def to_gen(_, {:error, error}), do: {:error, error} 34 | 35 | def to_gen(spec, generator) do 36 | with {:ok, g} <- Generatable.gen(spec) do 37 | [g | generator] 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/norm/generator.ex: -------------------------------------------------------------------------------- 1 | defmodule Norm.Generator do 2 | @moduledoc false 3 | # This module provides a wrapper struct for overriding generators of the other 4 | # conformable and generatable types. 5 | 6 | defstruct ~w|conformer generator|a 7 | 8 | def new(conformer, generator) do 9 | %__MODULE__{conformer: conformer, generator: generator} 10 | end 11 | 12 | defimpl Norm.Conformer.Conformable do 13 | # We just pass the conformer through here. We don't need to be involved. 14 | def conform(%{conformer: c}, input, path) do 15 | Norm.Conformer.Conformable.conform(c, input, path) 16 | end 17 | end 18 | 19 | defimpl Norm.Generatable do 20 | def gen(%{generator: gen}) do 21 | if gen == :null do 22 | raise Norm.GeneratorLibraryError 23 | else 24 | {:ok, gen} 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Norm.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.13.1" 5 | @source_url "https://github.com/elixir-toniq/norm" 6 | 7 | def project do 8 | [ 9 | app: :norm, 10 | version: @version, 11 | elixir: "~> 1.11", 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | start_permanent: Mix.env() == :prod, 14 | deps: deps(), 15 | description: description(), 16 | package: package(), 17 | name: "Norm", 18 | source_url: @source_url, 19 | docs: docs() 20 | ] 21 | end 22 | 23 | defp elixirc_paths(:test), do: ["lib", "test/support"] 24 | defp elixirc_paths(_), do: ["lib"] 25 | 26 | def application do 27 | [ 28 | env: [enable_contracts: true] 29 | ] 30 | end 31 | 32 | defp deps do 33 | [ 34 | {:credo, "~> 1.4", only: [:dev, :test], runtime: false}, 35 | {:stream_data, "~> 0.6 or ~> 1.0", optional: true}, 36 | {:ex_doc, "~> 0.19", only: [:dev, :test]} 37 | ] 38 | end 39 | 40 | def description do 41 | """ 42 | Norm is a system for specifying the structure of data. It can be used for 43 | validation and for generation of data. Norm does not provide any set of 44 | predicates and instead allows you to re-use any of your existing 45 | validations. 46 | """ 47 | end 48 | 49 | def package do 50 | [ 51 | name: "norm", 52 | licenses: ["MIT"], 53 | links: %{"GitHub" => @source_url} 54 | ] 55 | end 56 | 57 | def docs do 58 | [ 59 | source_ref: "v#{@version}", 60 | source_url: @source_url, 61 | main: "Norm" 62 | ] 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, 4 | "earmark": {:hex, :earmark, "1.4.4", "4821b8d05cda507189d51f2caeef370cf1e18ca5d7dfb7d31e9cafe6688106a4", [:mix], [], "hexpm", "1f93aba7340574847c0f609da787f0d79efcab51b044bb6e242cae5aca9d264d"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 6 | "ex_doc": {:hex, :ex_doc, "0.38.0", "0ab17291b71f9b2c479c0b92404107ac5005214872c3b43f845f6f644ba14f56", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "dee6d6485ef501384fbfc7c90cb0fe621636078bebc0f7a1fd2ddcc20b185013"}, 7 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 8 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 9 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 10 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 11 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 12 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 13 | "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, 14 | } 15 | -------------------------------------------------------------------------------- /test/norm/contract_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Norm.ContractTest do 2 | use ExUnit.Case, async: true 3 | 4 | test "success" do 5 | defmodule Success do 6 | use Norm 7 | 8 | @contract foo(n :: spec(is_integer())) :: spec(is_integer()) 9 | def foo(n), do: n 10 | end 11 | 12 | assert Success.foo(42) == 42 13 | end 14 | 15 | test "arg mismatch" do 16 | defmodule ArgMismatch do 17 | use Norm 18 | 19 | @contract foo(n :: spec(is_integer())) :: spec(is_integer()) 20 | def foo(n), do: n 21 | end 22 | 23 | assert_raise Norm.MismatchError, ~r/val: "42" fails: is_integer\(\)/, fn -> 24 | ArgMismatch.foo("42") 25 | end 26 | end 27 | 28 | test "result mismatch" do 29 | defmodule ResultMismatch do 30 | use Norm 31 | 32 | @contract foo(n :: spec(is_integer())) :: spec(is_binary()) 33 | def foo(n), do: n 34 | end 35 | 36 | assert_raise Norm.MismatchError, ~r/val: 42 fails: is_binary\(\)/, fn -> 37 | ResultMismatch.foo(42) 38 | end 39 | end 40 | 41 | test "bad contract" do 42 | expected = if version().minor >= 13, 43 | do: ~r/got: `@contract foo\(n\)`/, else: ~r/got: `@contract\(foo\(n\)\)`/ 44 | 45 | assert_raise ArgumentError, expected, fn -> 46 | defmodule BadContract do 47 | use Norm 48 | 49 | @contract foo(n) 50 | def foo(n), do: n 51 | end 52 | end 53 | end 54 | 55 | test "no function" do 56 | assert_raise ArgumentError, "contract for undefined function foo/0", fn -> 57 | defmodule NoFunction do 58 | use Norm 59 | 60 | @contract foo() :: spec(is_integer()) 61 | end 62 | end 63 | end 64 | 65 | test "function definition without parentheses" do 66 | defmodule WithoutParentheses do 67 | use Norm 68 | 69 | @contract fun() :: spec(is_integer()) 70 | def fun do 71 | 42 72 | end 73 | end 74 | 75 | assert WithoutParentheses.fun() == 42 76 | end 77 | 78 | test "non-contract function definition without parentheses" do 79 | defmodule WithoutParentheses2 do 80 | use Norm 81 | 82 | @contract fun(int :: spec(is_integer())) :: spec(is_integer()) 83 | def fun(int) do 84 | int * 2 85 | end 86 | 87 | def other do 88 | "Hello, world!" 89 | end 90 | end 91 | 92 | assert WithoutParentheses2.fun(50) == 100 93 | assert WithoutParentheses2.other() == "Hello, world!" 94 | end 95 | 96 | test "reflection" do 97 | defmodule Reflection do 98 | use Norm 99 | 100 | def int(), do: spec(is_integer()) 101 | 102 | @contract foo(a :: int(), int()) :: int() 103 | def foo(a, b), do: a + b 104 | end 105 | 106 | contract = Reflection.__contract__({:foo, 2}) 107 | 108 | assert inspect(contract) == 109 | "%Norm.Contract{args: [a: #Norm.Spec, arg2: #Norm.Spec], result: #Norm.Spec}" 110 | end 111 | 112 | defp version, do: Version.parse!(System.version()) 113 | end 114 | -------------------------------------------------------------------------------- /test/norm/core/alt_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Norm.Core.AltTest do 2 | use Norm.Case, async: true 3 | 4 | describe "generation" do 5 | test "returns one of the options" do 6 | spec = alt(s: spec(is_binary()), i: spec(is_integer()), a: spec(is_atom())) 7 | 8 | for {type, value} <- Enum.take(gen(spec), 5) do 9 | case type do 10 | :s -> 11 | assert is_binary(value) 12 | 13 | :i -> 14 | assert is_integer(value) 15 | 16 | :a -> 17 | assert is_atom(value) 18 | end 19 | end 20 | end 21 | end 22 | 23 | describe "inspect" do 24 | test "alts" do 25 | spec = alt(s: spec(is_binary()), i: spec(is_integer())) 26 | 27 | assert inspect(spec) == 28 | "#Norm.Alt<[s: #Norm.Spec, i: #Norm.Spec]>" 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/norm/core/any_of_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Norm.Core.AnyOfTest do 2 | use Norm.Case, async: true 3 | 4 | describe "conforming" do 5 | test "returns the first match" do 6 | union = one_of([:foo, spec(is_binary())]) 7 | 8 | assert :foo == conform!(:foo, union) 9 | assert "chris" == conform!("chris", union) 10 | assert {:error, errors} = conform(123, union) 11 | 12 | assert errors == [ 13 | %{spec: "is not an atom.", input: 123, path: []}, 14 | %{spec: "is_binary()", input: 123, path: []} 15 | ] 16 | end 17 | 18 | test "accepts nil if part of the union" do 19 | union = one_of([spec(is_nil()), spec(is_binary())]) 20 | 21 | assert nil == conform!(nil, union) 22 | assert "foo" == conform!("foo", union) 23 | assert {:error, errors} = conform(42, union) 24 | 25 | assert errors == [ 26 | %{spec: "is_nil()", input: 42, path: []}, 27 | %{spec: "is_binary()", input: 42, path: []} 28 | ] 29 | end 30 | end 31 | 32 | describe "generation" do 33 | property "randomly selects one of the options" do 34 | union = one_of([:foo, spec(is_binary())]) 35 | 36 | check all(e <- gen(union)) do 37 | assert e == :foo || is_binary(e) 38 | end 39 | end 40 | end 41 | 42 | test "inspect" do 43 | union = one_of([:foo, spec(is_binary())]) 44 | assert inspect(union) == "#Norm.OneOf<[:foo, #Norm.Spec]>" 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/norm/core/collection_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Norm.Core.CollectionTest do 2 | use Norm.Case, async: true 3 | 4 | test "inspect" do 5 | spec = coll_of(spec(is_atom())) 6 | assert inspect(spec) == "#Norm.CollOf<#Norm.Spec>" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/norm/core/delegate_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Norm.Core.DelegateTest do 2 | use Norm.Case, async: true 3 | 4 | defmodule TreeTest do 5 | def spec() do 6 | schema(%{ 7 | "value" => spec(is_integer()), 8 | "left" => delegate(&TreeTest.spec/0), 9 | "right" => delegate(&TreeTest.spec/0) 10 | }) 11 | end 12 | end 13 | 14 | describe "delegate/1" do 15 | test "can write recursive specs with 'delegate'" do 16 | assert {:ok, _} = conform(%{}, TreeTest.spec()) 17 | 18 | assert {:ok, _} = 19 | conform( 20 | %{"value" => 4, "left" => %{"value" => 2}, "right" => %{"value" => 12}}, 21 | TreeTest.spec() 22 | ) 23 | 24 | assert {:error, [%{input: "12", path: ["left", "left", "value"], spec: "is_integer()"}]} = 25 | conform( 26 | %{ 27 | "value" => 4, 28 | "left" => %{"value" => 2, "left" => %{"value" => "12"}}, 29 | "right" => %{"value" => 12} 30 | }, 31 | TreeTest.spec() 32 | ) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/norm/core/schema_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Norm.Core.SchemaTest do 2 | use Norm.Case, async: true 3 | 4 | defmodule User do 5 | import Norm 6 | 7 | defstruct ~w|name email age|a 8 | 9 | def s, 10 | do: 11 | schema(%__MODULE__{ 12 | name: spec(is_binary()), 13 | email: spec(is_binary()), 14 | age: spec(is_integer() and (&(&1 >= 0))) 15 | }) 16 | 17 | def chris do 18 | %__MODULE__{name: "chris", email: "c@keathley.io", age: 31} 19 | end 20 | end 21 | 22 | defmodule OtherUser do 23 | defstruct ~w|name email age|a 24 | end 25 | 26 | test "creates a re-usable schema" do 27 | s = schema(%{name: spec(is_binary())}) 28 | assert %{name: "Chris"} == conform!(%{name: "Chris"}, s) 29 | assert %{foo: "bar"} == conform!(%{foo: "bar"}, s) 30 | assert {:error, _errors} = conform(%{name: 123}, s) 31 | 32 | user = schema(%{user: schema(%{name: spec(is_binary())})}) 33 | assert %{user: %{name: "Chris"}} == conform!(%{user: %{name: "Chris"}}, user) 34 | end 35 | 36 | test "all keys in a schema are optional" do 37 | s = schema(%{name: spec(is_binary()), age: spec(is_integer())}) 38 | 39 | assert valid?(%{}, s) 40 | assert valid?(%{name: "chris"}, s) 41 | assert valid?(%{age: 123}, s) 42 | 43 | refute valid?(%{name: 123}, s) 44 | refute valid?(%{age: "11"}, s) 45 | end 46 | 47 | test "schemas allow additional keys" do 48 | s = schema(%{name: spec(is_binary())}) 49 | 50 | assert %{name: "chris", age: 31} == conform!(%{name: "chris", age: 31}, s) 51 | end 52 | 53 | test "works with boolean values" do 54 | s = schema(%{bool: spec(is_boolean())}) 55 | 56 | assert %{bool: true} == conform!(%{bool: true}, s) 57 | assert %{bool: false} == conform!(%{bool: false}, s) 58 | end 59 | 60 | test "allows keys to have nil values" do 61 | s = schema(%{foo: spec(is_nil())}) 62 | 63 | assert %{foo: nil} == conform!(%{foo: nil}, s) 64 | assert {:error, errors} = conform(%{foo: 123}, s) 65 | assert errors == [%{spec: "is_nil()", input: 123, path: [:foo]}] 66 | end 67 | 68 | test "schemas can be composed with other specs" do 69 | user_or_other = alt(user: User.s(), other: schema(%OtherUser{})) 70 | user = User.chris() 71 | other = %OtherUser{} 72 | 73 | assert {:user, user} == conform!(user, user_or_other) 74 | assert {:other, other} == conform!(other, user_or_other) 75 | assert {:error, errors} = conform(%{}, user_or_other) 76 | 77 | assert errors == [ 78 | %{spec: "Norm.Core.SchemaTest.User", input: %{}, path: [:user]}, 79 | %{spec: "Norm.Core.SchemaTest.OtherUser", input: %{}, path: [:other]} 80 | ] 81 | end 82 | 83 | test "can have nested alts" do 84 | s = schema(%{a: alt(bool: spec(is_boolean()), int: spec(is_integer()))}) 85 | 86 | assert %{a: {:bool, true}} == conform!(%{a: true}, s) 87 | assert %{a: {:bool, false}} == conform!(%{a: false}, s) 88 | assert %{a: {:int, 123}} == conform!(%{a: 123}, s) 89 | assert {:error, errors} = conform(%{a: "test"}, s) 90 | 91 | assert errors == [ 92 | %{spec: "is_boolean()", input: "test", path: [:a, :bool]}, 93 | %{spec: "is_integer()", input: "test", path: [:a, :int]} 94 | ] 95 | end 96 | 97 | test "works with string keys and atom keys" do 98 | user = 99 | schema(%{ 100 | "name" => spec(is_binary()), 101 | age: spec(is_integer()) 102 | }) 103 | 104 | input = %{ 105 | "name" => "chris", 106 | age: 31 107 | } 108 | 109 | assert input == conform!(input, user) 110 | assert {:error, errors} = conform(%{"name" => 31, age: "chris"}, user) 111 | 112 | assert errors == [ 113 | %{spec: "is_integer()", input: "chris", path: [:age]}, 114 | %{spec: "is_binary()", input: 31, path: ["name"]} 115 | ] 116 | end 117 | 118 | test "conforming struct input with a map schema" do 119 | assert %OtherUser{} == conform!(%OtherUser{}, schema(%{})) 120 | assert %OtherUser{name: "chris"} == conform!( 121 | %OtherUser{name: "chris"}, 122 | selection(schema(%{name: spec(is_binary())}))) 123 | end 124 | 125 | describe "schema/1 with struct" do 126 | test "fails non-structs when the schema is a struct" do 127 | input = Map.from_struct(User.chris()) 128 | 129 | assert {:error, errors} = conform(input, schema(%User{})) 130 | 131 | assert errors == [ 132 | %{spec: "Norm.Core.SchemaTest.User", input: %{age: 31, email: "c@keathley.io", name: "chris"}, path: []} 133 | ] 134 | end 135 | 136 | test "fails if the wrong struct is passed" do 137 | input = User.chris() 138 | 139 | assert {:error, errors} = conform(input, schema(%OtherUser{})) 140 | 141 | assert errors == [ 142 | %{spec: "Norm.Core.SchemaTest.OtherUser", input: %User{age: 31, email: "c@keathley.io", name: "chris"}, path: []} 143 | ] 144 | end 145 | 146 | test "can create a schema from a struct" do 147 | assert User.chris() == conform!(User.chris(), schema(%User{})) 148 | end 149 | 150 | test "can specify specs for keys" do 151 | input = User.chris() 152 | 153 | assert input == conform!(input, User.s()) 154 | assert {:error, errors} = conform(%User{name: :foo, age: "31", email: 42}, User.s()) 155 | 156 | assert MapSet.equal?(MapSet.new(errors), MapSet.new([ 157 | %{spec: "is_integer()", input: "31", path: [:age]}, 158 | %{spec: "is_binary()", input: 42, path: [:email]}, 159 | %{spec: "is_binary()", input: :foo, path: [:name]} 160 | ])) 161 | end 162 | 163 | test "only checks the keys that have specs" do 164 | input = User.chris() 165 | spec = schema(%User{name: spec(is_binary())}) 166 | 167 | assert input == conform!(input, spec) 168 | assert {:error, errors} = conform(%User{name: 23}, spec) 169 | assert errors == [%{spec: "is_binary()", input: 23, path: [:name]}] 170 | end 171 | 172 | defmodule Movie do 173 | defstruct directors: [:foo, :bar, :baz], producers: [] 174 | end 175 | 176 | test "allows defaults" do 177 | spec = schema(%Movie{}) 178 | assert movie = conform!(%Movie{}, spec) 179 | assert match?(%Movie{}, movie) 180 | end 181 | 182 | property "can generate proper structs" do 183 | check all(user <- gen(User.s())) do 184 | assert match?(%User{}, user) 185 | assert is_integer(user.age) and user.age >= 0 186 | assert is_binary(user.name) 187 | assert is_binary(user.email) 188 | end 189 | end 190 | 191 | property "can generate structs with a subset of keys specified" do 192 | check all(user <- gen(schema(%User{age: spec(is_integer() and (&(&1 > 0)))}))) do 193 | assert match?(%User{}, user) 194 | assert is_integer(user.age) and user.age >= 0 195 | assert is_nil(user.name) 196 | assert is_nil(user.email) 197 | end 198 | 199 | check all(movie <- gen(schema(%Movie{producers: spec(is_list())}))) do 200 | assert match?(%Movie{}, movie) 201 | assert movie.directors == [:foo, :bar, :baz] 202 | assert is_list(movie.producers) 203 | end 204 | end 205 | end 206 | 207 | describe "generation" do 208 | test "works with maps" do 209 | s = 210 | schema(%{ 211 | name: spec(is_binary()), 212 | age: spec(is_integer()) 213 | }) 214 | 215 | maps = 216 | s 217 | |> gen() 218 | |> Enum.take(10) 219 | 220 | for map <- maps do 221 | assert is_map(map) 222 | assert match?(%{name: _, age: _}, map) 223 | assert is_binary(map.name) 224 | assert is_integer(map.age) 225 | end 226 | end 227 | 228 | test "returns errors if it contains unknown generators" do 229 | s = 230 | schema(%{ 231 | age: spec(&(&1 > 0)) 232 | }) 233 | 234 | assert_raise Norm.GeneratorError, "Unable to create a generator for: &(&1 > 0)", fn -> 235 | gen(s) 236 | end 237 | end 238 | end 239 | 240 | describe "inspect" do 241 | test "map schemas" do 242 | s = schema(%{name: spec(is_binary()), age: spec(is_integer())}) 243 | assert inspect(s) == "#Norm.Schema<%{age: #Norm.Spec, name: #Norm.Spec}>" 244 | end 245 | 246 | test "struct schema" do 247 | assert inspect(User.s()) == "#Norm.Schema<%Norm.Core.SchemaTest.User{age: #Norm.Spec= 0)>, email: #Norm.Spec, name: #Norm.Spec}>" 248 | end 249 | end 250 | end 251 | -------------------------------------------------------------------------------- /test/norm/core/selection_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Norm.Core.SelectionTest do 2 | use Norm.Case, async: true 3 | 4 | defmodule Event do 5 | import Norm 6 | 7 | defstruct ~w|data|a 8 | 9 | def s do 10 | schema(%__MODULE__{ 11 | data: schema(%{ 12 | type: spec(is_atom()) 13 | }) 14 | }) 15 | end 16 | end 17 | 18 | def user_schema, do: schema(%{ 19 | name: spec(is_binary()), 20 | age: spec(is_integer() and (&(&1 > 0))), 21 | email: spec(is_binary() and (&(&1 =~ ~r/@/))) 22 | }) 23 | 24 | @input %{ 25 | name: "chris", 26 | age: 31, 27 | email: "c@keathley.io" 28 | } 29 | 30 | describe "selection/2" do 31 | test "can define selections of schemas" do 32 | assert @input == conform!(@input, selection(user_schema(), [:age])) 33 | assert @input == conform!(@input, selection(user_schema(), [:age, :name])) 34 | assert @input == conform!(@input, selection(user_schema(), [:age, :name, :email])) 35 | assert @input == conform!(@input, selection(schema(%{name: spec(is_binary())}), [:name])) 36 | assert {:error, errors} = conform(%{age: -100}, selection(user_schema(), [:age])) 37 | assert errors == [%{spec: "&(&1 > 0)", input: -100, path: [:age]}] 38 | end 39 | 40 | test "works with nested schemas" do 41 | schema = schema(%{user: user_schema()}) 42 | selection = selection(schema, user: [:age]) 43 | 44 | assert %{user: %{age: 31}} == conform!(%{user: %{age: 31}}, selection) 45 | assert {:error, errors} = conform(%{user: %{age: -100}}, selection) 46 | assert errors == [%{spec: "&(&1 > 0)", input: -100, path: [:user, :age]}] 47 | assert {:error, errors} = conform(%{user: %{name: "chris"}}, selection) 48 | assert errors == [%{spec: ":required", input: %{name: "chris"}, path: [:user, :age]}] 49 | assert {:error, errors} = conform(%{fauxuser: %{age: 31}}, selection) 50 | assert errors == [%{spec: ":required", input: %{fauxuser: %{age: 31}}, path: [:user]}] 51 | assert {:error, errors} = conform(%{user: nil}, selection) 52 | assert errors == [%{input: nil, path: [:user], spec: "not a map"}] 53 | end 54 | 55 | test "works with nested selections" do 56 | user_with_name = schema(%{user: selection(user_schema(), [:name])}) 57 | input = %{name: "chris"} 58 | assert %{user: input} == conform!(%{user: input}, selection(user_with_name)) 59 | 60 | assert_raise ArgumentError, fn -> 61 | user = schema(%{name: spec(is_binary()), age: spec(is_integer())}) 62 | required_user = selection(user) 63 | selection(schema(%{user: required_user}), [user: [:name]]) 64 | end 65 | end 66 | 67 | test "returns an error if a non map input is given" do 68 | assert {:error, errors} = conform(123, selection(user_schema())) 69 | assert errors == [ 70 | %{input: 123, path: [], spec: "not a map"} 71 | ] 72 | end 73 | 74 | test "if no keys are selected all keys are enforced recursively" do 75 | assert valid?(@input, selection(user_schema())) 76 | refute valid?(%{}, selection(user_schema())) 77 | refute valid?(%{name: "chris"}, selection(user_schema())) 78 | refute valid?(%{name: "chris", age: 31}, selection(user_schema())) 79 | end 80 | 81 | test "always returns missing keys even if the schema errors" do 82 | s = schema(%{ 83 | a: coll_of(selection(schema(%{b: spec(is_boolean())}))), 84 | c: spec(is_boolean()) 85 | }) 86 | assert {:error, errors} = conform(%{a: [%{b: "no_bool"}]}, selection(s, [:c])) 87 | assert errors == [ 88 | %{input: "no_bool", path: [:a, 0, :b], spec: "is_boolean()"}, 89 | %{input: %{a: [%{b: "no_bool"}]}, path: [:c], spec: ":required"}, 90 | ] 91 | end 92 | 93 | test "errors if there are keys that aren't specified in a schema" do 94 | assert_raise Norm.SpecError, fn -> 95 | selection(schema(%{age: spec(is_integer())}), [:name]) 96 | end 97 | 98 | assert_raise Norm.SpecError, fn -> 99 | selection(schema(%{user: schema(%{age: spec(is_integer())})}), user: [:name]) 100 | end 101 | 102 | assert_raise Norm.SpecError, fn -> 103 | selection(schema(%{user: schema(%{age: spec(is_integer())})}), foo: [:name]) 104 | end 105 | 106 | assert_raise Norm.SpecError, fn -> 107 | users = schema(%{ 108 | users: coll_of(schema(%{age: spec(is_integer)})), 109 | alts: alt([foo: :foo, bar: :bar]), 110 | one_of: one_of([:foo, :bar]), 111 | map_of: map_of(spec(is_atom), spec(is_atom)) 112 | }) 113 | selection(users, [:other]) 114 | end 115 | 116 | assert_raise Norm.SpecError, fn -> 117 | s = schema(%{count: spec(is_integer() and (& &1 > 0) or is_binary)}) 118 | selection(s, [:incorrect_key]) 119 | end 120 | end 121 | 122 | test "works with structs" do 123 | assert %Event{} = conform!(%Event{data: %{type: :foo}}, selection(Event.s())) 124 | 125 | assert {:error, errors} = conform(%{typo: %Event{}}, selection(schema(%{event: schema(%Event{data: spec(is_atom)})}))) 126 | assert errors == [ 127 | %{input: %{typo: %Norm.Core.SelectionTest.Event{data: nil}}, path: [:event], spec: ":required"} 128 | ] 129 | end 130 | 131 | test "allows default spec values" do 132 | assert %Event{} = conform!(%Event{data: %{type: :foo}}, selection(schema(%Event{}))) 133 | end 134 | 135 | test "returns deeply nested errors" do 136 | input = %{ 137 | data: %{ 138 | foo: :foo, 139 | bar: %{ 140 | inner: :inner, 141 | }, 142 | baz: %{} 143 | } 144 | } 145 | 146 | s = schema(%{ 147 | data: schema(%{ 148 | foo: spec(& &1 == :foo), 149 | bar: schema(%{ 150 | inner: spec(& &1 == :inner), 151 | }), 152 | baz: schema(%{ 153 | inner: spec(& &1 == :inner), 154 | }) 155 | }) 156 | }) 157 | 158 | assert {:error, errors} = conform(input, selection(s)) 159 | assert errors == [ 160 | %{input: %{}, path: [:data, :baz, :inner], spec: ":required"} 161 | ] 162 | end 163 | end 164 | 165 | describe "generation" do 166 | test "can generate values" do 167 | s = 168 | schema(%{ 169 | name: spec(is_binary()), 170 | age: spec(is_integer()) 171 | }) 172 | 173 | select = selection(s, [:name, :age]) 174 | 175 | maps = 176 | select 177 | |> gen() 178 | |> Enum.take(10) 179 | 180 | for map <- maps do 181 | assert is_map(map) 182 | assert match?(%{name: _, age: _}, map) 183 | assert is_binary(map.name) 184 | assert is_integer(map.age) 185 | end 186 | end 187 | 188 | test "can generate subsets" do 189 | s = 190 | schema(%{ 191 | name: spec(is_binary()), 192 | age: spec(is_integer()) 193 | }) 194 | 195 | select = selection(s, [:age]) 196 | 197 | maps = 198 | select 199 | |> gen() 200 | |> Enum.take(10) 201 | 202 | for map <- maps do 203 | assert is_map(map) 204 | assert match?(%{age: _}, map) 205 | assert is_integer(map.age) 206 | end 207 | end 208 | 209 | test "can generate inner schemas" do 210 | s = schema(%{ 211 | user: schema(%{ 212 | name: spec(is_binary()), 213 | age: spec(is_integer()) 214 | }) 215 | }) 216 | 217 | select = selection(s, user: [:age]) 218 | 219 | maps = 220 | select 221 | |> gen() 222 | |> Enum.take(10) 223 | 224 | for map <- maps do 225 | assert is_map(map) 226 | assert match?(%{user: %{age: _}}, map) 227 | assert is_integer(map.user.age) 228 | end 229 | end 230 | end 231 | 232 | describe "inspect" do 233 | test "single selection" do 234 | # The regex inspection changed in elixir 1.10. So for now we're going to support 235 | # both versions in the test. We can remove this once elixir 1.12 is out. 236 | if version().minor >= 10 do 237 | assert inspect(selection(user_schema())) == "#Norm.Selection<%{required: [:name, :email, :age], schema: #Norm.Schema<%{age: #Norm.Spec 0)>, email: #Norm.Spec, name: #Norm.Spec}>}>" 238 | assert inspect(selection(user_schema(), [:name])) == "#Norm.Selection<%{required: [:name], schema: #Norm.Schema<%{age: #Norm.Spec 0)>, email: #Norm.Spec, name: #Norm.Spec}>}>" 239 | else 240 | assert inspect(selection(user_schema())) == "#Norm.Selection<%{required: [:name, :email, :age], schema: #Norm.Schema<%{age: #Norm.Spec 0)>, email: #Norm.Spec, name: #Norm.Spec}>}>" 241 | assert inspect(selection(user_schema(), [:name])) == "#Norm.Selection<%{required: [:name], schema: #Norm.Schema<%{age: #Norm.Spec 0)>, email: #Norm.Spec, name: #Norm.Spec}>}>" 242 | end 243 | end 244 | end 245 | 246 | defp version do 247 | Version.parse!(System.version()) 248 | end 249 | end 250 | -------------------------------------------------------------------------------- /test/norm/core/spec_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Norm.Core.SpecTest do 2 | use Norm.Case, async: true 3 | 4 | defmodule Foo do 5 | def hello?(str), do: str == "hello" 6 | 7 | def match?(x, given), do: x == given 8 | end 9 | 10 | describe "spec/1" do 11 | test "can compose specs with 'and'" do 12 | hex = spec(is_binary() and (&String.starts_with?(&1, "#"))) 13 | 14 | assert "#000000" == conform!("#000000", hex) 15 | assert {:error, errors} = conform(nil, hex) 16 | assert errors == [%{spec: "is_binary()", input: nil, path: []}] 17 | assert {:error, errors} = conform("bad", hex) 18 | 19 | spec = if version().minor >= 13, do: "&String.starts_with?(&1, \"#\")", else: "&(String.starts_with?(&1, \"#\"))" 20 | assert errors == [%{spec: spec, input: "bad", path: []}] 21 | end 22 | 23 | test "'and' and 'or' can be chained" do 24 | s = spec(is_integer() and (&(&1 >= 21)) and (&(&1 < 30))) 25 | 26 | check all(i <- StreamData.integer(21..29)) do 27 | assert i == conform!(i, s) 28 | end 29 | end 30 | 31 | test "works with remote functions" do 32 | require Integer 33 | 34 | evens = spec(is_integer() and Integer.is_even()) 35 | assert 2 == conform!(2, evens) 36 | assert {:error, [%{spec: "Integer.is_even()", input: 3, path: []}]} == conform(3, evens) 37 | 38 | hello = spec(Foo.hello?()) 39 | assert "hello" == conform!("hello", hello) 40 | assert {:error, [%{spec: "Foo.hello?()", input: "foo", path: []}]} == conform("foo", hello) 41 | 42 | foo = spec(Foo.match?("foo")) 43 | assert "foo" == conform!("foo", foo) 44 | assert {:error, [%{spec: "Foo.match?(\"foo\")", input: "bar", path: []}]} == conform("bar", foo) 45 | end 46 | 47 | test "supports eliding of parenthesis around functions" do 48 | require Integer 49 | 50 | evens = spec(is_integer and Integer.is_even) 51 | assert conform!(2, evens) == 2 52 | assert {:error, errors} = conform("1", evens) 53 | assert errors == [ 54 | %{input: "1", path: [], spec: "is_integer()"} 55 | ] 56 | 57 | matcher = spec(Foo.match?("foo") and is_binary) 58 | assert conform!("foo", matcher) == "foo" 59 | assert {:error, errors} = conform("1", matcher) 60 | assert errors == [ 61 | %{input: "1", path: [], spec: "Foo.match?(\"foo\")"} 62 | ] 63 | end 64 | end 65 | 66 | describe "generation" do 67 | test "infers the type from the first predicate" do 68 | name = spec(fn x -> String.length(x) > 0 end and is_binary()) 69 | 70 | assert_raise Norm.GeneratorError, fn -> 71 | Enum.take(gen(name), 3) 72 | end 73 | end 74 | 75 | test "throws an error if it can't infer the generator" do 76 | assert_raise Norm.GeneratorError, fn -> 77 | Enum.take(gen(spec(&(String.length(&1) > 0))), 1) 78 | end 79 | end 80 | 81 | test "throws an error if the filter is too vague" do 82 | assert_raise StreamData.FilterTooNarrowError, fn -> 83 | Enum.take(gen(spec(is_binary() and (&(&1 =~ ~r/foobarbaz/)))), 1) 84 | end 85 | end 86 | 87 | test "works with 'and'" do 88 | name = spec(is_binary() and (&(String.length(&1) > 0))) 89 | 90 | for name <- Enum.take(gen(name), 3) do 91 | assert is_binary(name) 92 | assert String.length(name) > 0 93 | end 94 | 95 | age = spec(is_integer() and (&(&1 > 0))) 96 | 97 | for i <- Enum.take(gen(age), 3) do 98 | assert is_integer(i) 99 | assert i > 0 100 | end 101 | 102 | assert gen(spec(is_integer() and fn i -> i > 0 end and (&(&1 > 60)))) 103 | end 104 | 105 | test "works with 'or'" do 106 | name_or_age = spec(is_integer() or is_binary()) 107 | 108 | for f <- Enum.take(gen(name_or_age), 10) do 109 | assert is_binary(f) || is_integer(f) 110 | end 111 | end 112 | 113 | test "'or' returns an error if it can't infer both generators" do 114 | assert_raise Norm.GeneratorError, fn -> 115 | Enum.take(gen(spec(is_integer() or (&(&1 > 0)))), 1) 116 | end 117 | end 118 | 119 | property "works with remote functions" do 120 | require Integer 121 | evens = spec(is_integer() and Integer.is_even()) 122 | 123 | check all(i <- gen(evens)) do 124 | assert is_integer(i) 125 | assert rem(i, 2) == 0 126 | end 127 | end 128 | end 129 | 130 | describe "inspect" do 131 | test "predicate" do 132 | assert inspect(spec(is_integer())) == "#Norm.Spec" 133 | end 134 | 135 | test "lambda" do 136 | assert inspect(spec(&(&1 >= 21))) == "#Norm.Spec<&(&1 >= 21)>" 137 | end 138 | 139 | test "and" do 140 | assert inspect(spec(is_integer() and (&(&1 >= 21)))) == 141 | "#Norm.Spec= 21)>" 142 | end 143 | 144 | test "or" do 145 | assert inspect(spec(is_integer() or is_float())) == 146 | "#Norm.Spec" 147 | end 148 | end 149 | 150 | defp version, do: Version.parse!(System.version()) 151 | end 152 | -------------------------------------------------------------------------------- /test/norm/generator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Norm.GeneratorTest do 2 | use ExUnit.Case, async: true 3 | import Norm 4 | 5 | alias Norm.Generator 6 | 7 | describe "null generators" do 8 | test "continue to work for conforming" do 9 | spec = Generator.new(spec(is_integer()), :null) 10 | 11 | assert 123 == conform!(123, spec) 12 | assert {:error, [%{spec: "is_integer()", input: "foo", path: []}]} = conform("foo", spec) 13 | end 14 | 15 | test "raises when generating" do 16 | spec = Generator.new(spec(is_integer()), :null) 17 | 18 | assert_raise Norm.GeneratorLibraryError, fn -> 19 | gen(spec) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/norm/generators/primitives_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Norm.Generators.GeneratorTest do 2 | use ExUnit.Case, async: true 3 | import Norm 4 | 5 | describe "generates primitive types" do 6 | test "generates values from spec(is_atom())" do 7 | spec = spec(is_atom()) 8 | output = generate_value_for_spec(spec) 9 | 10 | assert is_atom(output) 11 | end 12 | 13 | test "generates values from spec(is_boolean())" do 14 | spec = spec(is_boolean()) 15 | output = generate_value_for_spec(spec) 16 | 17 | assert is_boolean(output) 18 | end 19 | 20 | test "generates values from spec(is_binary())" do 21 | spec = spec(is_binary()) 22 | output = generate_value_for_spec(spec) 23 | 24 | assert is_binary(output) 25 | end 26 | 27 | test "generates values from spec(is_bitstring())" do 28 | spec = spec(is_bitstring()) 29 | output = generate_value_for_spec(spec) 30 | 31 | assert is_bitstring(output) 32 | end 33 | 34 | test "generates values from spec(is_float())" do 35 | spec = spec(is_float()) 36 | output = generate_value_for_spec(spec) 37 | 38 | assert is_float(output) 39 | end 40 | 41 | test "generates values from spec(is_integer())" do 42 | spec = spec(is_integer()) 43 | output = generate_value_for_spec(spec) 44 | 45 | assert is_integer(output) 46 | end 47 | end 48 | 49 | defp generate_value_for_spec(spec) do 50 | spec 51 | |> gen() 52 | |> Enum.take(1) 53 | |> hd() 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/norm_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NormTest do 2 | use ExUnit.Case, async: true 3 | doctest Norm, import: true 4 | import Norm 5 | import ExUnitProperties, except: [gen: 1] 6 | 7 | describe "conform" do 8 | test "accepts specs" do 9 | assert {:ok, 123} = conform(123, spec(is_integer())) 10 | end 11 | 12 | test "can match atoms" do 13 | assert :ok == conform!(:ok, :ok) 14 | assert {:error, errors} = conform("foo", :ok) 15 | assert errors == [%{spec: "is not an atom.", input: "foo", path: []}] 16 | assert {:error, errors} = conform(:mismatch, :ok) 17 | assert errors == [%{spec: "== :ok", input: :mismatch, path: []}] 18 | end 19 | 20 | test "can match patterns of tuples" do 21 | ok = {:ok, spec(is_integer())} 22 | error = {:error, spec(is_binary())} 23 | three = {spec(is_integer()), spec(is_integer()), spec(is_integer())} 24 | 25 | assert {:ok, 123} == conform!({:ok, 123}, ok) 26 | 27 | assert {:error, "something's wrong"} == conform!({:error, "something's wrong"}, error) 28 | 29 | assert {1, 2, 3} == conform!({1, 2, 3}, three) 30 | assert {:error, errors} = conform({1, :bar, "foo"}, three) 31 | 32 | assert errors == [ 33 | %{spec: "is_integer()", input: :bar, path: [1]}, 34 | %{spec: "is_integer()", input: "foo", path: [2]} 35 | ] 36 | 37 | assert {:error, errors} = conform({:ok, "foo"}, ok) 38 | assert errors == [%{spec: "is_integer()", input: "foo", path: [1]}] 39 | 40 | assert {:error, errors} = conform({:ok, "foo", 123}, ok) 41 | assert errors == [%{spec: "incorrect tuple size", input: {:ok, "foo", 123}, path: []}] 42 | 43 | assert {:error, errors} = conform({:ok, 123, "foo"}, ok) 44 | assert errors == [%{spec: "incorrect tuple size", input: {:ok, 123, "foo"}, path: []}] 45 | 46 | assert {:error, errors} = conform(:error, ok) 47 | assert errors == [%{input: :error, path: [], spec: "not a tuple"}] 48 | end 49 | 50 | test "tuples can be composed with schema's and selections" do 51 | user = schema(%{name: spec(is_binary()), age: spec(is_integer())}) 52 | ok = {:ok, selection(user, [:name])} 53 | 54 | assert {:ok, %{name: "chris", age: 31}} == conform!({:ok, %{name: "chris", age: 31}}, ok) 55 | assert {:error, errors} = conform({:ok, %{age: 31}}, ok) 56 | assert errors == [%{spec: ":required", input: %{age: 31}, path: [1, :name]}] 57 | end 58 | 59 | test "can spec keyword lists" do 60 | list = coll_of(one_of([name: spec(is_atom())])) 61 | assert conform!([name: :foo], list) == [name: :foo] 62 | end 63 | end 64 | 65 | describe "gen" do 66 | property "works with atoms" do 67 | check all(foo <- gen(:foo)) do 68 | assert is_atom(foo) 69 | assert foo == :foo 70 | end 71 | 72 | check all(a <- gen(spec(is_atom()))) do 73 | assert is_atom(a) 74 | end 75 | end 76 | 77 | property "works with tuples" do 78 | ok = {:ok, schema(%{name: spec(is_binary())})} 79 | 80 | check all(tuple <- gen(ok)) do 81 | assert {:ok, user} = tuple 82 | assert Map.keys(user) == [:name] 83 | assert is_binary(user.name) 84 | end 85 | 86 | assert_raise Norm.GeneratorError, fn -> 87 | gen({spec(&(&1 > 0)), spec(is_binary())}) 88 | end 89 | 90 | ints = {spec(is_binary()), spec(is_integer()), spec(is_integer())} 91 | 92 | check all(is <- gen(ints)) do 93 | assert {a, b, c} = is 94 | assert is_binary(a) 95 | assert is_integer(b) 96 | assert is_integer(c) 97 | end 98 | end 99 | end 100 | 101 | describe "with_gen" do 102 | test "overrides the default generator" do 103 | spec = with_gen(spec(is_integer()), gen(spec(is_binary()))) 104 | for str <- Enum.take(gen(spec), 5), do: assert(is_binary(str)) 105 | 106 | spec = with_gen(schema(%{foo: spec(is_integer())}), StreamData.constant("foo")) 107 | for str <- Enum.take(gen(spec), 5), do: assert(str == "foo") 108 | end 109 | end 110 | 111 | describe "selection/1" do 112 | test "returns an error if passed a non-schema" do 113 | assert_raise FunctionClauseError, fn -> 114 | selection(spec(is_binary()), []) 115 | end 116 | end 117 | end 118 | 119 | describe "alt/1" do 120 | test "returns errors" do 121 | spec = alt(a: schema(%{name: spec(is_binary())}), b: spec(is_binary())) 122 | 123 | assert {:a, %{name: "alice"}} == conform!(%{name: "alice"}, spec) 124 | assert {:b, "foo"} == conform!("foo", spec) 125 | assert {:error, errors} = conform(%{name: :alice}, spec) 126 | 127 | assert errors == [ 128 | %{spec: "is_binary()", input: :alice, path: [:a, :name]}, 129 | %{spec: "is_binary()", input: %{name: :alice}, path: [:b]} 130 | ] 131 | end 132 | 133 | test "can generate data" do 134 | spec = alt(a: spec(is_binary()), b: spec(is_integer())) 135 | 136 | vals = 137 | spec 138 | |> gen() 139 | |> Enum.take(5) 140 | 141 | assert Enum.count(vals) == 5 142 | 143 | for val <- vals do 144 | assert is_binary(val) || is_integer(val) 145 | end 146 | end 147 | end 148 | 149 | describe "map_of" do 150 | test "can spec generic maps" do 151 | spec = map_of(spec(is_integer()), spec(is_atom())) 152 | assert %{1 => :foo, 2 => :bar} == conform!(%{1 => :foo, 2 => :bar}, spec) 153 | end 154 | 155 | test "doesn't throw for non-enumerable inputs" do 156 | spec = map_of(spec(is_integer()), spec(is_atom())) 157 | 158 | assert {:error, errors} = conform("not-a-map!", spec) 159 | assert errors == [ 160 | %{spec: "not enumerable", input: "not-a-map!", path: []} 161 | ] 162 | end 163 | 164 | test "doesn't throw for list inputs" do 165 | spec = map_of(spec(is_integer()), spec(is_atom())) 166 | 167 | assert {:error, errors} = conform([1, 2, 3], spec) 168 | assert errors == [ 169 | %{spec: "does not match kind: &:erlang.is_map/1", input: [1, 2, 3], path: []} 170 | ] 171 | end 172 | 173 | property "can be generated" do 174 | check all m <- gen(map_of(spec(is_integer()), spec(is_atom()))) do 175 | assert is_map(m) 176 | for k <- Map.keys(m), do: assert is_integer(k) 177 | for v <- Map.values(m), do: assert is_atom(v) 178 | end 179 | end 180 | end 181 | 182 | describe "coll_of/2" do 183 | test "can spec collections" do 184 | spec = coll_of(spec(is_atom())) 185 | assert [:foo, :bar, :baz] == conform!([:foo, :bar, :baz], spec) 186 | assert {:error, errors} = conform([:foo, 1, "test"], spec) 187 | assert errors == [ 188 | %{spec: "is_atom()", input: 1, path: [1]}, 189 | %{spec: "is_atom()", input: "test", path: [2]} 190 | ] 191 | end 192 | 193 | test "doesn't throw for non-enumerable inputs" do 194 | spec = coll_of(spec(is_integer())) 195 | 196 | assert {:error, errors} = conform("not-a-collection!", spec) 197 | assert errors == [ 198 | %{spec: "not enumerable", input: "not-a-collection!", path: []} 199 | ] 200 | end 201 | 202 | test "conforming returns the conformed values" do 203 | spec = coll_of(schema(%{name: spec(is_binary())})) 204 | input = [ 205 | %{name: "chris", age: 31, email: "c@keathley.io"}, 206 | %{name: "andra", age: 30} 207 | ] 208 | 209 | assert conform!(input, spec) == [ 210 | %{name: "chris", age: 31, email: "c@keathley.io"}, 211 | %{name: "andra", age: 30} 212 | ] 213 | 214 | input = [ 215 | %{age: 31, email: "c@keathley.io", name: nil}, 216 | %{name: :andra, age: 30}, 217 | ] 218 | assert {:error, errors} = conform(input, spec) 219 | assert errors == [ 220 | %{spec: "is_binary()", input: nil, path: [0, :name]}, 221 | %{spec: "is_binary()", input: :andra, path: [1, :name]} 222 | ] 223 | end 224 | 225 | test "can enforce distinct elements" do 226 | spec = coll_of(spec(is_integer()), distinct: true) 227 | 228 | assert {:error, errors} = conform([1,1,1], spec) 229 | assert errors == [%{spec: "distinct?", input: [1, 1, 1], path: []}] 230 | end 231 | 232 | test "can enforce min and max counts" do 233 | spec = coll_of(spec(is_integer()), min_count: 2, max_count: 3) 234 | assert [1, 1] == conform!([1, 1], spec) 235 | assert [1, 1, 1] == conform!([1, 1, 1], spec) 236 | assert {:error, [%{spec: "min_count: 2", input: [1], path: []}]} == conform([1], spec) 237 | assert {:error, [%{spec: "max_count: 3", input: [1, 1, 1, 1], path: []}]} == conform([1, 1, 1, 1], spec) 238 | 239 | spec = coll_of(spec(is_integer()), min_count: 3, max_count: 3) 240 | assert [1, 1, 1] == conform!([1, 1, 1], spec) 241 | end 242 | 243 | test "min count must be less than or equal to max count" do 244 | assert_raise ArgumentError, fn -> 245 | coll_of(spec(is_integer()), min_count: 3, max_count: 2) 246 | end 247 | end 248 | 249 | test "tuples don't flatten good results" do 250 | s = coll_of({spec(is_atom()), coll_of(spec(is_binary()))}) 251 | assert [foo: ["foo"]] == conform!([foo: ["foo"]], s) 252 | end 253 | 254 | test "can be used to spec keyword lists" do 255 | opts = one_of([ 256 | {:name, spec(is_atom())}, 257 | {:timeout, spec(is_integer())} 258 | ]) 259 | spec = coll_of(opts) 260 | list = [name: :storage, timeout: 3_000] 261 | 262 | assert list == conform!(list, spec) 263 | assert {:error, _errors} = conform([{:foo, :bar} | list], spec) 264 | 265 | assert list == conform!(list, coll_of(opts, [min_count: 2, distinct: true])) 266 | assert {:error, errors} = conform([], coll_of(opts, [min_count: 2, distinct: true])) 267 | assert get_in(errors, [Access.at(0), :spec]) == "min_count: 2" 268 | end 269 | 270 | property "can be generated" do 271 | check all is <- gen(coll_of(spec(is_integer()))) do 272 | for i <- is, do: assert is_integer(i) 273 | end 274 | 275 | check all is <- gen(coll_of(spec(is_integer()), min_count: 3, max_count: 6)) do 276 | length = Enum.count(is) 277 | assert 3 <= length and length <= 6 278 | end 279 | 280 | check all is <- gen(coll_of(spec(is_integer()), distinct: true)) do 281 | assert Enum.uniq(is) == is 282 | end 283 | end 284 | end 285 | end 286 | -------------------------------------------------------------------------------- /test/support/norm_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Norm.Case do 2 | @moduledoc false 3 | use ExUnit.CaseTemplate 4 | 5 | using do 6 | quote do 7 | import Norm 8 | import ExUnitProperties, except: [gen: 1] 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/support/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Norm.User do 2 | @moduledoc false 3 | defstruct [:name, :age] 4 | end 5 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.ensure_all_started(:stream_data) 2 | ExUnit.start() 3 | --------------------------------------------------------------------------------