├── .credo.exs ├── .dialyzer_ignore ├── .formatter.exs ├── .github └── workflows │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config └── config.exs ├── coveralls.json ├── lib ├── litmus.ex └── litmus │ ├── default.ex │ ├── plug.ex │ ├── required.ex │ ├── type.ex │ └── type │ ├── any.ex │ ├── boolean.ex │ ├── date_time.ex │ ├── list.ex │ ├── number.ex │ ├── string.ex │ └── string │ ├── regex.ex │ └── replace.ex ├── mix.exs ├── mix.lock └── test ├── litmus ├── default_test.exs ├── plug_test.exs ├── required_test.exs ├── type │ ├── any_test.exs │ ├── boolean_test.exs │ ├── date_time_test.exs │ ├── list_test.exs │ ├── number_test.exs │ └── string_test.exs └── type_test.exs ├── litmus_test.exs └── 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/", "src/", "test/", "web/", "apps/"], 25 | excluded: [~r"/_build/", ~r"/deps/"] 26 | }, 27 | # 28 | # If you create your own checks, you must specify the source files for 29 | # them here, so they can be loaded by Credo before running the analysis. 30 | # 31 | requires: [], 32 | # 33 | # If you want to enforce a style guide and need a more traditional linting 34 | # experience, you can change `strict` to `true` below: 35 | # 36 | strict: false, 37 | # 38 | # If you want to use uncolored output by default, you can change `color` 39 | # to `false` below: 40 | # 41 | color: true, 42 | # 43 | # You can customize the parameters of any check by adding a second element 44 | # to the tuple. 45 | # 46 | # To disable a check put `false` as second element: 47 | # 48 | # {Credo.Check.Design.DuplicatedCode, false} 49 | # 50 | checks: [ 51 | # 52 | ## Consistency Checks 53 | # 54 | {Credo.Check.Consistency.ExceptionNames}, 55 | {Credo.Check.Consistency.LineEndings}, 56 | {Credo.Check.Consistency.ParameterPatternMatching}, 57 | {Credo.Check.Consistency.SpaceAroundOperators}, 58 | {Credo.Check.Consistency.SpaceInParentheses}, 59 | {Credo.Check.Consistency.TabsOrSpaces}, 60 | 61 | # 62 | ## Design Checks 63 | # 64 | # You can customize the priority of any check 65 | # Priority values are: `low, normal, high, higher` 66 | # 67 | {Credo.Check.Design.AliasUsage, priority: :low}, 68 | # For some checks, you can also set other parameters 69 | # 70 | # If you don't want the `setup` and `test` macro calls in ExUnit tests 71 | # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just 72 | # set the `excluded_macros` parameter to `[:schema, :setup, :test]`. 73 | # 74 | {Credo.Check.Design.DuplicatedCode, excluded_macros: []}, 75 | # You can also customize the exit_status of each check. 76 | # If you don't want TODO comments to cause `mix credo` to fail, just 77 | # set this value to 0 (zero). 78 | # 79 | {Credo.Check.Design.TagTODO, exit_status: 2}, 80 | {Credo.Check.Design.TagFIXME}, 81 | 82 | # 83 | ## Readability Checks 84 | # 85 | {Credo.Check.Readability.AliasOrder}, 86 | {Credo.Check.Readability.FunctionNames}, 87 | {Credo.Check.Readability.LargeNumbers}, 88 | {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 98}, 89 | {Credo.Check.Readability.ModuleAttributeNames}, 90 | {Credo.Check.Readability.ModuleDoc}, 91 | {Credo.Check.Readability.ModuleNames}, 92 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs}, 93 | {Credo.Check.Readability.ParenthesesInCondition}, 94 | {Credo.Check.Readability.PredicateFunctionNames}, 95 | {Credo.Check.Readability.PreferImplicitTry}, 96 | {Credo.Check.Readability.RedundantBlankLines}, 97 | {Credo.Check.Readability.StringSigils}, 98 | {Credo.Check.Readability.TrailingBlankLine}, 99 | {Credo.Check.Readability.TrailingWhiteSpace}, 100 | {Credo.Check.Readability.VariableNames}, 101 | {Credo.Check.Readability.Semicolons}, 102 | {Credo.Check.Readability.SpaceAfterCommas}, 103 | 104 | # 105 | ## Refactoring Opportunities 106 | # 107 | {Credo.Check.Refactor.DoubleBooleanNegation}, 108 | {Credo.Check.Refactor.CondStatements}, 109 | {Credo.Check.Refactor.CyclomaticComplexity}, 110 | {Credo.Check.Refactor.FunctionArity}, 111 | {Credo.Check.Refactor.LongQuoteBlocks}, 112 | {Credo.Check.Refactor.MatchInCondition}, 113 | {Credo.Check.Refactor.NegatedConditionsInUnless}, 114 | {Credo.Check.Refactor.NegatedConditionsWithElse}, 115 | {Credo.Check.Refactor.Nesting}, 116 | {Credo.Check.Refactor.PipeChainStart, 117 | excluded_argument_types: [:atom, :binary, :fn, :keyword], excluded_functions: []}, 118 | {Credo.Check.Refactor.UnlessWithElse}, 119 | 120 | # 121 | ## Warnings 122 | # 123 | {Credo.Check.Warning.BoolOperationOnSameValues}, 124 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck}, 125 | {Credo.Check.Warning.IExPry}, 126 | {Credo.Check.Warning.IoInspect}, 127 | {Credo.Check.Warning.LazyLogging}, 128 | {Credo.Check.Warning.OperationOnSameValues}, 129 | {Credo.Check.Warning.OperationWithConstantResult}, 130 | {Credo.Check.Warning.UnusedEnumOperation}, 131 | {Credo.Check.Warning.UnusedFileOperation}, 132 | {Credo.Check.Warning.UnusedKeywordOperation}, 133 | {Credo.Check.Warning.UnusedListOperation}, 134 | {Credo.Check.Warning.UnusedPathOperation}, 135 | {Credo.Check.Warning.UnusedRegexOperation}, 136 | {Credo.Check.Warning.UnusedStringOperation}, 137 | {Credo.Check.Warning.UnusedTupleOperation}, 138 | {Credo.Check.Warning.RaiseInsideRescue}, 139 | 140 | # 141 | # Controversial and experimental checks (opt-in, just remove `, false`) 142 | # 143 | {Credo.Check.Refactor.ABCSize, false}, 144 | {Credo.Check.Refactor.AppendSingleItem, false}, 145 | {Credo.Check.Refactor.VariableRebinding, false}, 146 | {Credo.Check.Warning.MapGetUnsafePass, false}, 147 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 148 | 149 | # 150 | # Deprecated checks (these will be deleted after a grace period) 151 | # 152 | {Credo.Check.Readability.Specs, false} 153 | 154 | # 155 | # Custom checks can be created using `mix credo.gen.check`. 156 | # 157 | ] 158 | } 159 | ] 160 | } 161 | -------------------------------------------------------------------------------- /.dialyzer_ignore: -------------------------------------------------------------------------------- 1 | Unknown function 'Elixir.Litmus.Type.Atom':'__impl__'/1 2 | Unknown function 'Elixir.Litmus.Type.BitString':'__impl__'/1 3 | Unknown function 'Elixir.Litmus.Type.Float':'__impl__'/1 4 | Unknown function 'Elixir.Litmus.Type.Function':'__impl__'/1 5 | Unknown function 'Elixir.Litmus.Type.Integer':'__impl__'/1 6 | Unknown function 'Elixir.Litmus.Type.List':'__impl__'/1 7 | Unknown function 'Elixir.Litmus.Type.Map':'__impl__'/1 8 | Unknown function 'Elixir.Litmus.Type.PID':'__impl__'/1 9 | Unknown function 'Elixir.Litmus.Type.Port':'__impl__'/1 10 | Unknown function 'Elixir.Litmus.Type.Reference':'__impl__'/1 11 | Unknown function 'Elixir.Litmus.Type.Tuple':'__impl__'/1 12 | Call to missing or unexported function 'Elixir.Litmus.Type.List':'__impl__'/1 13 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: push 4 | 5 | jobs: 6 | test: 7 | name: Test 8 | runs-on: ubuntu-latest 9 | env: 10 | ImageOS: ubuntu20 11 | MIX_ENV: test 12 | 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v2 16 | 17 | # Setup elixir/erlang 18 | - name: Set up elixir 19 | uses: erlef/setup-beam@v1 20 | with: 21 | otp-version: 24.0 22 | elixir-version: 1.12 23 | rebar3-version: 3.14.2 24 | 25 | - name: Install System Dependencies 26 | shell: bash 27 | run: | 28 | mix local.hex --force 29 | mix deps.get 30 | mix compile --warnings-as-errors 31 | 32 | - name: Run tests and enforce coverage 33 | env: 34 | ELASTIC_HOST_V2: "${{ secrets.ELASTIC_HOST_V2 }}" 35 | ELASTIC_PASSWORD: "${{ secrets.ELASTIC_PASSWORD }}" 36 | MIX_ENV: test 37 | run: mix coveralls.html --max-cases 1 38 | 39 | - name: Compress coverage directory 40 | if: failure() 41 | shell: bash 42 | run: test -d cover && tar czvf ~/cover.tar.gz cover 43 | 44 | - name: Archive code coverage results 45 | if: failure() 46 | uses: actions/upload-artifact@v2 47 | with: 48 | name: code-coverage-report 49 | path: ~/cover.tar.gz 50 | 51 | lint-and-format: 52 | name: Lint and Format 53 | runs-on: ubuntu-latest 54 | env: 55 | ImageOS: ubuntu20 56 | MIX_ENV: test 57 | 58 | steps: 59 | - name: Checkout repository 60 | uses: actions/checkout@v2 61 | 62 | # Setup elixir/erlang 63 | - name: Set up elixir 64 | uses: erlef/setup-beam@v1 65 | with: 66 | otp-version: 24.0 67 | elixir-version: 1.12 68 | rebar3-version: 3.14.2 69 | 70 | - name: Install System Dependencies 71 | shell: bash 72 | run: | 73 | mix local.hex --force 74 | mix deps.get 75 | mix compile --warnings-as-errors 76 | 77 | - name: Run linter 78 | shell: bash 79 | run: mix credo 80 | 81 | - name: Run formatter 82 | shell: bash 83 | run: mix format --check-formatted --dry-run 84 | 85 | dialyzer: 86 | name: Dialyzer 87 | runs-on: ubuntu-latest 88 | env: 89 | ImageOS: ubuntu20 90 | MIX_ENV: test 91 | 92 | steps: 93 | - name: Checkout repository 94 | uses: actions/checkout@v2 95 | 96 | # Setup elixir/erlang 97 | - name: Set up elixir 98 | uses: erlef/setup-beam@v1 99 | with: 100 | otp-version: 24.0 101 | elixir-version: 1.12 102 | rebar3-version: 3.14.2 103 | 104 | - name: Install System Dependencies 105 | shell: bash 106 | run: | 107 | mix local.hex --force 108 | mix deps.get 109 | mix compile --warnings-as-errors 110 | 111 | - name: Run dialyzer 112 | shell: bash 113 | run : mix dialyzer --halt-exit-status 114 | 115 | 116 | -------------------------------------------------------------------------------- /.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 | litmus-*.tar 24 | 25 | .elixir_ls/ 26 | .DS_Store 27 | package.json 28 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 1.0.1 (2020-09-02) 2 | 3 | * **dependencies:** Upgraded dependencies ([#23](https://github.com/lob/litmus/pull/31)) 4 | 5 | ### 1.0.0 (2019-06-17) 6 | 7 | ##### New Features 8 | 9 | * **string:** Adds :replace option ([#26](https://github.com/lob/litmus/pull/26)) 10 | * **list:** Adds :unique option ([#25](https://github.com/lob/litmus/pull/25)) 11 | * **datetime:** Allows DateTime structs as values and default values ([#24](https://github.com/lob/litmus/pull/24)) 12 | 13 | ##### Internal Changes 14 | 15 | * **typespecs:** Corrects typespecs ([#29](https://github.com/lob/litmus/pull/29)) 16 | * **required:** Exit early for non-required, non-present field ([#28](https://github.com/lob/litmus/pull/28)) 17 | * **dependencies:** Support for Elixir 1.8 and upgraded dependencies ([#23](https://github.com/lob/litmus/pull/23)) 18 | 19 | ### 0.6.0 (2019-05-10) 20 | 21 | ##### New Features 22 | 23 | * **default:** Adds default option to all types ([#22](https://github.com/lob/litmus/pull/22)) 24 | 25 | ### 0.5.0 (2019-01-02) 26 | 27 | ##### New Features 28 | 29 | * **string:** Allows nil strings when not required ([#21](https://github.com/lob/litmus/pull/21)) 30 | 31 | ### 0.4.0 (2018-08-28) 32 | 33 | ##### New Features 34 | 35 | * **datetime:** Added DateTime type ([#20](https://github.com/lob/litmus/pull/20)) 36 | 37 | ### 0.3.0 (2018-08-21) 38 | 39 | ##### New Features 40 | 41 | * **plug:** Added a validation plug for use with Plug's Router ([#17](https://github.com/lob/litmus/pull/17)) 42 | 43 | ### 0.2.0 (2018-08-06) 44 | 45 | ##### New Features 46 | 47 | * **list-validation:** Added validation functions and test cases for List data type ([#16](https://github.com/lob/litmus/pull/16)) ([d1977270](https://github.com/lob/litmus/commit/d1977270dc746788966543d646b6612b8621bd09)) 48 | 49 | #### 0.1.1 (2018-08-02) 50 | 51 | ##### Chores 52 | 53 | * **spec:** changes binary to String.t() ([#14](https://github.com/lob/litmus/pull/14)) ([f4f7eb23](https://github.com/lob/litmus/commit/f4f7eb23cf21c9d09127eb1f653afb0d013a7169)) 54 | 55 | ##### Bug Fixes 56 | 57 | * **allow-nil:** Convert nil value of data to empty string ([#15](https://github.com/lob/litmus/pull/15)) ([81e570c6](https://github.com/lob/litmus/commit/81e570c600492807fef73a2ac9c47d7c24232ef6)) 58 | 59 | ### 0.1.0 (2018-08-01) 60 | 61 | ##### Chores 62 | 63 | * **add-test:** Added excoveralls, travis, credo and dialyxir ([#3](https://github.com/lob/litmus/pull/3)) ([8d374e3a](https://github.com/lob/litmus/commit/8d374e3ab8d5441cd4ed6da3fc45eaf4718fda43)) 64 | * **add-files:** Added README.md, LICENSE and CHANGELOG.md files ([#1](https://github.com/lob/litmus/pull/1)) ([99628e7b](https://github.com/lob/litmus/commit/99628e7b89062bab1ac58d6a23227fd456bad4b9)) 65 | * **ex_doc:** added ex_doc to the project ([#2](https://github.com/lob/litmus/pull/2)) ([b47ce805](https://github.com/lob/litmus/commit/b47ce8054087785461eeba7863bd68a75f8d1d0a)) 66 | 67 | ##### New Features 68 | 69 | * **number-validation:** Added validation functions and test cases for Number data type ([#10](https://github.com/lob/litmus/pull/10)) ([1088adbb](https://github.com/lob/litmus/commit/1088adbb6b9083d257e3ed0afb904afd0f1e173e)) 70 | * **boolean-validation:** Added validation functions and test cases for Boolean data type ([#11](https://github.com/lob/litmus/pull/11)) ([c68d44cb](https://github.com/lob/litmus/commit/c68d44cb686df93519b6db2a5bf1773609e415b9)) 71 | * **string-validation:** 72 | * Convert number and boolean field values to string ([#9](https://github.com/lob/litmus/pull/9)) ([9f849144](https://github.com/lob/litmus/commit/9f84914479411b126a9930b31132f67b06bfd87a)) 73 | * Added trim and regex validation functions for String data type ([#8](https://github.com/lob/litmus/pull/8)) ([45f29c1b](https://github.com/lob/litmus/commit/45f29c1b6eeaffd9fffa0a2019f3741f54893e88)) 74 | * Added length related validation functions for String data type ([#7](https://github.com/lob/litmus/pull/7)) ([faa1cbc1](https://github.com/lob/litmus/commit/faa1cbc1dbd55b71a2617d855924b00e24238141)) 75 | * **any-validation:** Added validation functions for Any data type ([#6](https://github.com/lob/litmus/pull/6)) ([840ac038](https://github.com/lob/litmus/commit/840ac03837212322d4ead54801448f575b35e62b)) 76 | * **main-validation:** Added the main entry point for validation ([#5](https://github.com/lob/litmus/pull/5)) ([4b0d3ac2](https://github.com/lob/litmus/commit/4b0d3ac25e69dff8c9a1ae277cef9c4fb02f94eb)) 77 | * **add-type:** Add schemas for each data type ([#4](https://github.com/lob/litmus/pull/4)) ([28c6aa33](https://github.com/lob/litmus/commit/28c6aa33daafa5aff3ee4dd190832039a0a26c8c)) 78 | 79 | ##### Bug Fixes 80 | 81 | * **package:** Publish library on Hex ([#13](https://github.com/lob/litmus/pull/13)) ([430d3241](https://github.com/lob/litmus/commit/430d3241e3c971355c0946c815548039e8267d1b)) 82 | * **boolean:** simplifies check_booelan_values return ([#12](https://github.com/lob/litmus/pull/12)) ([1da07f68](https://github.com/lob/litmus/commit/1da07f6878b339a76f5b3a5f83b9c9cfde16735b)) 83 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Lob 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 | # Litmus 2 | 3 | [![Hex.pm](https://img.shields.io/hexpm/v/litmus.svg)](https://hex.pm/packages/litmus) 4 | [![Build Docs](https://img.shields.io/badge/hexdocs-release-blue.svg)](https://hexdocs.pm/litmus/Litmus.html) 5 | [![Build Status](https://travis-ci.org/lob/litmus.svg?branch=master)](https://travis-ci.org/lob/litmus) 6 | 7 | Data validation in Elixir 8 | 9 | ## Installation 10 | 11 | The package can be installed by adding `litmus` to your list of dependencies in 12 | `mix.exs`: 13 | 14 | ```elixir 15 | def deps do 16 | [ 17 | {:litmus, "~> 1.0.2"} 18 | ] 19 | end 20 | ``` 21 | 22 | ## Usage 23 | 24 | Litmus validates data against a predefined schema with the `Litmus.validate/2` 25 | function. 26 | 27 | If the data is valid, the function returns `{:ok, data}`. The data returned 28 | will be coerced according to the provided schema. 29 | 30 | If the data passed does not follow the rules defined in the schema, the 31 | function returns `{:error, error_message}`. It will also return an error when 32 | receiving a field that has not been specified in the provided schema. 33 | 34 | ```elixir 35 | schema = %{ 36 | "id" => %Litmus.Type.Any{ 37 | required: true 38 | }, 39 | "username" => %Litmus.Type.String{ 40 | min_length: 6, 41 | required: true 42 | }, 43 | "pin" => %Litmus.Type.Number{ 44 | min: 1000, 45 | max: 9999, 46 | required: true 47 | }, 48 | "new_user" => %Litmus.Type.Boolean{ 49 | truthy: ["1"], 50 | falsy: ["0"] 51 | }, 52 | "account_ids" => %Litmus.Type.List{ 53 | max_length: 3, 54 | type: :number 55 | }, 56 | "remember_me" => %Litmus.Type.Boolean{ 57 | default: false 58 | } 59 | } 60 | 61 | params = %{ 62 | "id" => 1, 63 | "username" => "user@123", 64 | "pin" => 1234, 65 | "new_user" => "1", 66 | "account_ids" => [1, 3, 9] 67 | } 68 | 69 | Litmus.validate(params, schema) 70 | # => {:ok, 71 | # %{ 72 | # "id" => 1, 73 | # "new_user" => true, 74 | # "pin" => 1234, 75 | # "username" => "user@123", 76 | # "account_ids" => [1, 3, 9], 77 | # "remember_me" => false 78 | # } 79 | # } 80 | 81 | Litmus.validate(%{}, schema) 82 | # => {:error, "id is required"} 83 | ``` 84 | 85 | ## Supported Types 86 | 87 | Litmus currently supports the following types. 88 | 89 | * `Litmus.Type.Any` 90 | * `Litmus.Type.Boolean` 91 | * `Litmus.Type.DateTime` 92 | * `Litmus.Type.List` 93 | * `Litmus.Type.Number` 94 | * `Litmus.Type.String` 95 | 96 | ## Plug Integration 97 | 98 | Litmus comes with a Plug for easy integration with Plug's built-in router. You can automatically validate query 99 | parameters and body parameters by passing the `litmus_query` and `litmus_body` private options to each route. When 100 | declaring the plug you must include a `on_error/2` function to be called when validation fails. It is recommended that 101 | you initialize this Plug between the `:match` and `:dispatch` plugs. If you want processing to stop on a validation 102 | error, be sure to halt the request with `Plug.Conn.halt/1`. 103 | 104 | #### Example 105 | 106 | ```elixir 107 | defmodule MyRouter do 108 | use Plug.Router 109 | 110 | plug(Plug.Parsers, parsers: [:urlencoded, :multipart]) 111 | 112 | plug(:match) 113 | 114 | plug(Litmus.Plug, on_error: &__MODULE__.on_error/2) 115 | 116 | plug(:dispatch) 117 | 118 | @schema %{ 119 | "id" => %Litmus.Type.Number{ 120 | required: true 121 | } 122 | } 123 | 124 | get "/test", private: %{litmus_query: @schema} do 125 | Plug.Conn.send_resp(conn, 200, "items") 126 | end 127 | 128 | post "/test", private: %{litmus_body: @schema} do 129 | Plug.Conn.send_resp(conn, 200, "items") 130 | end 131 | 132 | def on_error(conn, error_message) do 133 | conn 134 | |> Plug.Conn.send_resp(400, error_message) 135 | |> Plug.Conn.halt() 136 | end 137 | end 138 | ``` 139 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure your application as: 12 | # 13 | # config :litmus, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:litmus, :key) 18 | # 19 | # You can also configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "coverage_options": { 3 | "minimum_coverage": 100, 4 | "treat_no_relevant_lines_as_covered": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /lib/litmus.ex: -------------------------------------------------------------------------------- 1 | defmodule Litmus do 2 | @moduledoc """ 3 | Litmus is a data validation library for Elixir. 4 | """ 5 | 6 | alias Litmus.Type 7 | 8 | @doc """ 9 | Validates and converts data based on a schema. 10 | 11 | ## Examples 12 | 13 | iex> Litmus.validate(%{"id" => "123"}, %{"id" => %Litmus.Type.Number{}}) 14 | {:ok, %{"id" => 123}} 15 | 16 | iex> Litmus.validate(%{"id" => "asdf"}, %{"id" => %Litmus.Type.Number{}}) 17 | {:error, "id must be a number"} 18 | 19 | """ 20 | @spec validate(map, map) :: {:ok, map} | {:error, String.t()} 21 | def validate(data, schema) do 22 | case validate_allowed_params(data, schema) do 23 | :ok -> validate_schema(data, schema) 24 | {:error, msg} -> {:error, msg} 25 | end 26 | end 27 | 28 | @spec validate_allowed_params(map, map) :: :ok | {:error, String.t()} 29 | defp validate_allowed_params(data, schema) do 30 | result = Map.keys(data) -- Map.keys(schema) 31 | 32 | case result do 33 | [] -> :ok 34 | [field | _rest] -> {:error, "#{field} is not allowed"} 35 | end 36 | end 37 | 38 | @spec validate_schema(map, map) :: {:ok, map} | {:error, String.t()} 39 | defp validate_schema(data, schema) do 40 | Enum.reduce_while(schema, {:ok, data}, fn {field, type}, {:ok, modified_data} -> 41 | case Type.validate(type, field, modified_data) do 42 | {:error, msg} -> 43 | {:halt, {:error, msg}} 44 | 45 | {:ok, new_data} -> 46 | {:cont, {:ok, new_data}} 47 | end 48 | end) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/litmus/default.ex: -------------------------------------------------------------------------------- 1 | defmodule Litmus.Default do 2 | @moduledoc false 3 | 4 | alias Litmus.Type.Any.NoDefault 5 | 6 | @spec validate(map, term, map) :: {:ok, map} 7 | def validate(%{default: default_value}, field, params) when default_value != NoDefault do 8 | {:ok, Map.put_new(params, field, default_value)} 9 | end 10 | 11 | def validate(_, _, params), do: {:ok, params} 12 | end 13 | -------------------------------------------------------------------------------- /lib/litmus/plug.ex: -------------------------------------------------------------------------------- 1 | defmodule Litmus.Plug do 2 | @moduledoc false 3 | 4 | @spec init(Plug.opts()) :: Plug.opts() 5 | def init(opts), do: opts 6 | 7 | @spec call(Plug.Conn.t(), Plug.opts()) :: Plug.Conn.t() 8 | def call(conn, opts) do 9 | conn = Plug.Conn.fetch_query_params(conn) 10 | 11 | with {:ok, conn} <- validate_query_params(conn), 12 | {:ok, conn} <- validate_body_params(conn) do 13 | conn 14 | else 15 | {:error, message} -> opts[:on_error].(conn, message) 16 | end 17 | end 18 | 19 | @spec validate_query_params(Plug.Conn.t()) :: {:ok, Plug.Conn.t()} | {:error, String.t()} 20 | def validate_query_params(conn = %Plug.Conn{private: %{litmus_query: schema}}) do 21 | case Litmus.validate(conn.query_params, schema) do 22 | {:ok, new_params} -> {:ok, %Plug.Conn{conn | query_params: new_params}} 23 | {:error, message} -> {:error, message} 24 | end 25 | end 26 | 27 | def validate_query_params(conn), do: {:ok, conn} 28 | 29 | @spec validate_body_params(Plug.Conn.t()) :: {:ok, Plug.Conn.t()} | {:error, String.t()} 30 | def validate_body_params(conn = %Plug.Conn{private: %{litmus_body: schema}}) do 31 | case Litmus.validate(conn.body_params, schema) do 32 | {:ok, new_params} -> {:ok, %Plug.Conn{conn | body_params: new_params}} 33 | {:error, message} -> {:error, message} 34 | end 35 | end 36 | 37 | def validate_body_params(conn), do: {:ok, conn} 38 | end 39 | -------------------------------------------------------------------------------- /lib/litmus/required.ex: -------------------------------------------------------------------------------- 1 | defmodule Litmus.Required do 2 | @moduledoc false 3 | 4 | @spec validate(map, term, map) :: {:ok | :ok_not_present, map} | {:error, String.t()} 5 | def validate(%{required: true}, field, params) do 6 | if Map.has_key?(params, field) && params[field] != nil do 7 | {:ok, params} 8 | else 9 | {:error, "#{field} is required"} 10 | end 11 | end 12 | 13 | def validate(%{required: false}, field, params) do 14 | if Map.has_key?(params, field) do 15 | {:ok, params} 16 | else 17 | {:ok_not_present, params} 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/litmus/type.ex: -------------------------------------------------------------------------------- 1 | defprotocol Litmus.Type do 2 | @moduledoc false 3 | 4 | alias Litmus.Type 5 | 6 | @type t :: 7 | Type.Any.t() 8 | | Type.Boolean.t() 9 | | Type.DateTime.t() 10 | | Type.List.t() 11 | | Type.Number.t() 12 | | Type.String.t() 13 | 14 | @spec validate(t(), term, map) :: {:ok, map} | {:error, String.t()} 15 | def validate(type, field, data) 16 | end 17 | -------------------------------------------------------------------------------- /lib/litmus/type/any.ex: -------------------------------------------------------------------------------- 1 | defmodule Litmus.Type.Any do 2 | @moduledoc """ 3 | This type provides validation for any type of value. 4 | 5 | ## Options 6 | 7 | * `:default` - Setting `:default` will populate a field with the provided 8 | value, assuming that it is not present already. If a field already has a 9 | value present, it will not be altered. 10 | 11 | * `:required` - Setting `:required` to `true` will cause a validation error 12 | when a field is not present or the value is `nil`. Allowed values for 13 | required are `true` and `false`. The default is `false`. 14 | 15 | ## Examples 16 | 17 | iex> schema = %{"id" => %Litmus.Type.Any{required: true}} 18 | iex> Litmus.validate(%{"id" => 1}, schema) 19 | {:ok, %{"id" => 1}} 20 | 21 | iex> schema = %{"id" => %Litmus.Type.Any{default: "new_id"}} 22 | iex> Litmus.validate(%{}, schema) 23 | {:ok, %{"id" => "new_id"}} 24 | 25 | iex> schema = %{"id" => %Litmus.Type.Any{required: true}} 26 | iex> Litmus.validate(%{}, schema) 27 | {:error, "id is required"} 28 | 29 | iex> schema = %{"id" => %Litmus.Type.Any{required: true}} 30 | iex> Litmus.validate(%{"id" => nil}, schema) 31 | {:error, "id is required"} 32 | 33 | """ 34 | 35 | alias Litmus.{Default, Required} 36 | 37 | defstruct default: Litmus.Type.Any.NoDefault, 38 | required: false 39 | 40 | @type t :: %__MODULE__{ 41 | default: any, 42 | required: boolean 43 | } 44 | 45 | @spec validate_field(t, term, map) :: {:ok, map} | {:error, String.t()} 46 | def validate_field(type, field, data) do 47 | case Required.validate(type, field, data) do 48 | {:ok, data} -> {:ok, data} 49 | {:ok_not_present, data} -> Default.validate(type, field, data) 50 | {:error, msg} -> {:error, msg} 51 | end 52 | end 53 | 54 | defimpl Litmus.Type do 55 | alias Litmus.Type 56 | 57 | @spec validate(Type.t(), term, map) :: {:ok, map} | {:error, String.t()} 58 | def validate(type, field, data), do: Type.Any.validate_field(type, field, data) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/litmus/type/boolean.ex: -------------------------------------------------------------------------------- 1 | defmodule Litmus.Type.Boolean do 2 | @moduledoc """ 3 | This type validates and converts values to booleans. It converts truthy and 4 | falsy values to `true` or `false`. 5 | 6 | ## Options 7 | 8 | * `:default` - Setting `:default` will populate a field with the provided 9 | value, assuming that it is not present already. If a field already has a 10 | value present, it will not be altered. 11 | 12 | * `:required` - Setting `:required` to `true` will cause a validation error 13 | when a field is not present or the value is `nil`. Allowed values for 14 | required are `true` and `false`. The default is `false`. 15 | 16 | * `:truthy` - Allows additional values, i.e. truthy values to be considered 17 | valid booleans by converting them to `true` during validation. Allowed value 18 | is an array of strings, numbers, or booleans. The default is `[true, "true"]` 19 | 20 | * `:falsy` - Allows additional values, i.e. falsy values to be considered 21 | valid booleans by converting them to `false` during validation. Allowed value 22 | is an array of strings, number or boolean values. The default is `[false, 23 | "false"]` 24 | 25 | ## Examples 26 | 27 | iex> schema = %{ 28 | ...> "new_user" => %Litmus.Type.Boolean{ 29 | ...> truthy: ["1"], 30 | ...> falsy: ["0"] 31 | ...> } 32 | ...> } 33 | iex> params = %{"new_user" => "1"} 34 | iex> Litmus.validate(params, schema) 35 | {:ok, %{"new_user" => true}} 36 | 37 | iex> schema = %{ 38 | ...> "new_user" => %Litmus.Type.Boolean{ 39 | ...> default: false 40 | ...> } 41 | ...> } 42 | iex> Litmus.validate(%{}, schema) 43 | {:ok, %{"new_user" => false}} 44 | 45 | iex> schema = %{"new_user" => %Litmus.Type.Boolean{}} 46 | iex> params = %{"new_user" => 0} 47 | iex> Litmus.validate(params, schema) 48 | {:error, "new_user must be a boolean"} 49 | 50 | """ 51 | 52 | alias Litmus.{Default, Required} 53 | 54 | @truthy_default [true, "true"] 55 | @falsy_default [false, "false"] 56 | 57 | defstruct default: Litmus.Type.Any.NoDefault, 58 | truthy: @truthy_default, 59 | falsy: @falsy_default, 60 | required: false 61 | 62 | @type t :: %__MODULE__{ 63 | default: any, 64 | truthy: [term], 65 | falsy: [term], 66 | required: boolean 67 | } 68 | 69 | @spec validate_field(t, term, map) :: {:ok, map} | {:error, String.t()} 70 | def validate_field(type, field, data) do 71 | with {:ok, data} <- Required.validate(type, field, data), 72 | {:ok, data} <- truthy_falsy_validate(type, field, data) do 73 | {:ok, data} 74 | else 75 | {:ok_not_present, data} -> Default.validate(type, field, data) 76 | {:error, msg} -> {:error, msg} 77 | end 78 | end 79 | 80 | @spec check_boolean_values(term, [term], [term]) :: boolean 81 | defp check_boolean_values(initial_value, additional_values, default_values) 82 | when is_binary(initial_value) do 83 | allowed_values = 84 | additional_values 85 | |> (&(&1 ++ default_values)).() 86 | |> Enum.uniq() 87 | |> Enum.map(fn item -> 88 | if is_binary(item) do 89 | String.downcase(item) 90 | end 91 | end) 92 | 93 | String.downcase(initial_value) in allowed_values 94 | end 95 | 96 | defp check_boolean_values(initial_value, additional_values, default_values) do 97 | initial_value in Enum.uniq(additional_values ++ default_values) 98 | end 99 | 100 | @spec truthy_falsy_validate(t, term, map) :: {:ok, map} | {:error, String.t()} 101 | defp truthy_falsy_validate(%__MODULE__{falsy: falsy, truthy: truthy}, field, params) do 102 | cond do 103 | params[field] == nil -> 104 | {:ok, params} 105 | 106 | check_boolean_values(params[field], truthy, @truthy_default) -> 107 | {:ok, Map.replace!(params, field, true)} 108 | 109 | check_boolean_values(params[field], falsy, @falsy_default) -> 110 | {:ok, Map.replace!(params, field, false)} 111 | 112 | true -> 113 | {:error, "#{field} must be a boolean"} 114 | end 115 | end 116 | 117 | defimpl Litmus.Type do 118 | alias Litmus.Type 119 | 120 | @spec validate(Type.t(), term, map) :: {:ok, map} | {:error, String.t()} 121 | def validate(type, field, data), do: Type.Boolean.validate_field(type, field, data) 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /lib/litmus/type/date_time.ex: -------------------------------------------------------------------------------- 1 | defmodule Litmus.Type.DateTime do 2 | @moduledoc """ 3 | This type validates DateTimes. It accepts either `DateTime` structs or 4 | ISO-8601 strings. ISO-8601 datetime with timezone strings will be converted 5 | into `DateTime`s. 6 | 7 | ## Options 8 | 9 | * `:default` - Setting `:default` will populate a field with the provided 10 | value, assuming that it is not present already. If a field already has a 11 | value present, it will not be altered. 12 | 13 | * `:required` - Setting `:required` to `true` will cause a validation error 14 | when a field is not present or the value is `nil`. Allowed values for 15 | required are `true` and `false`. The default is `false`. 16 | 17 | ## Examples 18 | 19 | iex> schema = %{"start_date" => %Litmus.Type.DateTime{}} 20 | iex> {:ok, %{"start_date" => datetime}} = Litmus.validate(%{"start_date" => "2017-06-18T05:45:33Z"}, schema) 21 | iex> DateTime.to_iso8601(datetime) 22 | "2017-06-18T05:45:33Z" 23 | 24 | iex> {:ok, default_datetime, _} = DateTime.from_iso8601("2019-05-01T06:25:00-0700") 25 | ...> schema = %{ 26 | ...> "start_date" => %Litmus.Type.DateTime{ 27 | ...> default: default_datetime 28 | ...> } 29 | ...> } 30 | iex> {:ok, %{"start_date" => datetime}} = Litmus.validate(%{}, schema) 31 | iex> DateTime.to_iso8601(datetime) 32 | "2019-05-01T13:25:00Z" 33 | 34 | """ 35 | 36 | alias Litmus.{Default, Required} 37 | alias Litmus.Type 38 | 39 | defstruct default: Litmus.Type.Any.NoDefault, 40 | required: false 41 | 42 | @type t :: %__MODULE__{ 43 | default: any, 44 | required: boolean 45 | } 46 | 47 | @spec validate_field(t, term, map) :: {:ok, map} | {:error, String.t()} 48 | def validate_field(type, field, data) do 49 | with {:ok, data} <- Required.validate(type, field, data), 50 | {:ok, data} <- convert(type, field, data) do 51 | {:ok, data} 52 | else 53 | {:ok_not_present, data} -> Default.validate(type, field, data) 54 | {:error, msg} -> {:error, msg} 55 | end 56 | end 57 | 58 | @spec convert(t, term, map) :: {:ok, map} | {:error, String.t()} 59 | defp convert(%__MODULE__{}, field, params) do 60 | cond do 61 | params[field] == nil -> 62 | {:ok, params} 63 | 64 | is_binary(params[field]) -> 65 | case DateTime.from_iso8601(params[field]) do 66 | {:ok, date_time, _utc_offset} -> {:ok, Map.put(params, field, date_time)} 67 | {:error, _} -> error_tuple(field) 68 | end 69 | 70 | datetime?(params[field]) -> 71 | {:ok, params} 72 | 73 | true -> 74 | error_tuple(field) 75 | end 76 | end 77 | 78 | @spec datetime?(term) :: boolean 79 | defp datetime?(%DateTime{}), do: true 80 | defp datetime?(_), do: false 81 | 82 | @spec error_tuple(String.t()) :: {:error, String.t()} 83 | defp error_tuple(field) do 84 | {:error, "#{field} must be a valid ISO-8601 datetime"} 85 | end 86 | 87 | defimpl Litmus.Type do 88 | alias Litmus.Type 89 | 90 | @spec validate(Type.t(), term, map) :: {:ok, map} | {:error, String.t()} 91 | def validate(type, field, data), do: Type.DateTime.validate_field(type, field, data) 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/litmus/type/list.ex: -------------------------------------------------------------------------------- 1 | defmodule Litmus.Type.List do 2 | @moduledoc """ 3 | This type validates that a value is list. 4 | 5 | ## Options 6 | 7 | * `:default` - Setting `:default` will populate a field with the provided 8 | value, assuming that it is not present already. If a field already has a 9 | value present, it will not be altered. 10 | 11 | * `:min_length` - Specifies the minimum list length. Allowed values are 12 | non-negative integers. 13 | 14 | * `:max_length` - Specifies the maximum list length. Allowed values are 15 | non-negative integers. 16 | 17 | * `:length` - Specifies the exact list length. Allowed values are 18 | non-negative integers. 19 | 20 | * `:required` - Setting `:required` to `true` will cause a validation error 21 | when a field is not present or the value is `nil`. Allowed values for 22 | required are `true` and `false`. The default is `false`. 23 | 24 | * `:type` - Specifies the data type of elements in the list. Allowed values 25 | are are atoms `:atom, :boolean, :number and :string`. Default value is `nil`. 26 | If `nil`, any element type is allowed in the list. 27 | 28 | * `:unique` - Setting `:unique` to true will validate that all values in 29 | the list are unique. The default value is `false`. 30 | 31 | ## Examples 32 | 33 | iex> schema = %{ 34 | ...> "ids" => %Litmus.Type.List{ 35 | ...> min_length: 1, 36 | ...> max_length: 5, 37 | ...> type: :number 38 | ...> } 39 | ...> } 40 | iex> Litmus.validate(%{"ids" => [1, 2]}, schema) 41 | {:ok, %{"ids" => [1, 2]}} 42 | iex> Litmus.validate(%{"ids" => [1, "a"]}, schema) 43 | {:error, "ids must be a list of numbers"} 44 | 45 | iex> schema = %{ 46 | ...> "ids" => %Litmus.Type.List{ 47 | ...> default: [] 48 | ...> } 49 | ...> } 50 | iex> Litmus.validate(%{}, schema) 51 | {:ok, %{"ids" => []}} 52 | 53 | """ 54 | 55 | alias Litmus.{Default, Required} 56 | alias Litmus.Type 57 | 58 | defstruct [ 59 | :min_length, 60 | :max_length, 61 | :length, 62 | :type, 63 | default: Litmus.Type.Any.NoDefault, 64 | required: false, 65 | unique: false 66 | ] 67 | 68 | @type t :: %__MODULE__{ 69 | default: any, 70 | min_length: non_neg_integer | nil, 71 | max_length: non_neg_integer | nil, 72 | length: non_neg_integer | nil, 73 | type: atom | nil, 74 | required: boolean, 75 | unique: boolean 76 | } 77 | 78 | @spec validate_field(t, term, map) :: {:ok, map} | {:error, String.t()} 79 | def validate_field(type, field, data) do 80 | with {:ok, data} <- Required.validate(type, field, data), 81 | {:ok, data} <- validate_list(type, field, data), 82 | {:ok, data} <- type_validate(type, field, data), 83 | {:ok, data} <- min_length_validate(type, field, data), 84 | {:ok, data} <- max_length_validate(type, field, data), 85 | {:ok, data} <- length_validate(type, field, data), 86 | {:ok, data} <- unique_validate(type, field, data) do 87 | {:ok, data} 88 | else 89 | {:ok_not_present, data} -> Default.validate(type, field, data) 90 | {:error, msg} -> {:error, msg} 91 | end 92 | end 93 | 94 | @spec validate_list(t, term, map) :: {:ok, map} | {:error, String.t()} 95 | defp validate_list(%__MODULE__{}, field, params) do 96 | cond do 97 | params[field] == nil -> 98 | {:ok, params} 99 | 100 | is_list(params[field]) -> 101 | {:ok, params} 102 | 103 | true -> 104 | {:error, "#{field} must be a list"} 105 | end 106 | end 107 | 108 | @spec min_length_validate(t, term, map) :: {:ok, map} | {:error, String.t()} 109 | defp min_length_validate(%__MODULE__{min_length: nil}, _field, params) do 110 | {:ok, params} 111 | end 112 | 113 | defp min_length_validate(%__MODULE__{min_length: min_length}, field, params) 114 | when is_integer(min_length) and min_length >= 0 do 115 | if length(params[field]) < min_length do 116 | {:error, "#{field} must not be below length of #{min_length}"} 117 | else 118 | {:ok, params} 119 | end 120 | end 121 | 122 | @spec max_length_validate(t, term, map) :: {:ok, map} | {:error, String.t()} 123 | defp max_length_validate(%__MODULE__{max_length: nil}, _field, params) do 124 | {:ok, params} 125 | end 126 | 127 | defp max_length_validate(%__MODULE__{max_length: max_length}, field, params) 128 | when is_integer(max_length) and max_length >= 0 do 129 | if length(params[field]) > max_length do 130 | {:error, "#{field} must not exceed length of #{max_length}"} 131 | else 132 | {:ok, params} 133 | end 134 | end 135 | 136 | @spec length_validate(t, term, map) :: {:ok, map} | {:error, String.t()} 137 | defp length_validate(%__MODULE__{length: nil}, _field, params) do 138 | {:ok, params} 139 | end 140 | 141 | defp length_validate(%__MODULE__{length: length}, field, params) 142 | when is_integer(length) and length >= 0 do 143 | if length(params[field]) != length do 144 | {:error, "#{field} length must be of #{length} length"} 145 | else 146 | {:ok, params} 147 | end 148 | end 149 | 150 | @spec type_validate(t, term, map) :: {:ok, map} | {:error, String.t()} 151 | defp type_validate(%__MODULE__{type: nil}, _field, params) do 152 | {:ok, params} 153 | end 154 | 155 | defp type_validate(%__MODULE__{type: type}, field, params) do 156 | case type do 157 | :atom -> validate_atom(params, field) 158 | :boolean -> validate_boolean(params, field) 159 | :number -> validate_number(params, field) 160 | :string -> validate_string(params, field) 161 | end 162 | end 163 | 164 | @spec validate_atom(map, term) :: {:ok, map} | {:error, String.t()} 165 | defp validate_atom(params, field) do 166 | if Enum.all?(params[field], &is_atom/1) do 167 | {:ok, params} 168 | else 169 | {:error, "#{field} must be a list of atoms"} 170 | end 171 | end 172 | 173 | @spec validate_boolean(map, term) :: {:ok, map} | {:error, String.t()} 174 | defp validate_boolean(params, field) do 175 | if Enum.all?(params[field], &is_boolean/1) do 176 | {:ok, params} 177 | else 178 | {:error, "#{field} must be a list of boolean"} 179 | end 180 | end 181 | 182 | @spec validate_number(map, term) :: {:ok, map} | {:error, String.t()} 183 | defp validate_number(params, field) do 184 | if Enum.all?(params[field], &is_number/1) do 185 | {:ok, params} 186 | else 187 | {:error, "#{field} must be a list of numbers"} 188 | end 189 | end 190 | 191 | @spec validate_string(map, term) :: {:ok, map} | {:error, String.t()} 192 | defp validate_string(params, field) do 193 | if Enum.all?(params[field], &is_binary/1) do 194 | {:ok, params} 195 | else 196 | {:error, "#{field} must be a list of strings"} 197 | end 198 | end 199 | 200 | @spec unique_validate(t, term, map) :: {:ok, map} | {:error, String.t()} 201 | defp unique_validate(%__MODULE__{unique: false}, _field, params) do 202 | {:ok, params} 203 | end 204 | 205 | defp unique_validate(%__MODULE__{unique: true}, field, params) do 206 | list = params[field] 207 | 208 | if uniq?(list, %{}) do 209 | {:ok, params} 210 | else 211 | {:error, "#{field} cannot contain duplicate values"} 212 | end 213 | end 214 | 215 | @spec uniq?([term], map) :: boolean 216 | defp uniq?([], _set), do: true 217 | 218 | defp uniq?([head | tail], set) do 219 | case set do 220 | %{^head => true} -> false 221 | %{} -> uniq?(tail, Map.put(set, head, true)) 222 | end 223 | end 224 | 225 | defimpl Litmus.Type do 226 | alias Litmus.Type 227 | 228 | @spec validate(Type.t(), term, map) :: {:ok, map} | {:error, String.t()} 229 | def validate(type, field, data), do: Type.List.validate_field(type, field, data) 230 | end 231 | end 232 | -------------------------------------------------------------------------------- /lib/litmus/type/number.ex: -------------------------------------------------------------------------------- 1 | defmodule Litmus.Type.Number do 2 | @moduledoc """ 3 | This type validates that values are numbers, and converts them to numbers if 4 | possible. It converts "stringified" numerical values to numbers. 5 | 6 | ## Options 7 | 8 | * `:default` - Setting `:default` will populate a field with the provided 9 | value, assuming that it is not present already. If a field already has a 10 | value present, it will not be altered. 11 | 12 | * `:min` - Specifies the minimum value of the field. 13 | 14 | * `:max` - Specifies the maximum value of the field. 15 | 16 | * `:integer` - Specifies that the number must be an integer (no floating 17 | point). Allowed values are `true` and `false`. The default is `false`. 18 | 19 | * `:required` - Setting `:required` to `true` will cause a validation error 20 | when a field is not present or the value is `nil`. Allowed values for 21 | required are `true` and `false`. The default is `false`. 22 | 23 | ## Examples 24 | 25 | iex> schema = %{ 26 | ...> "id" => %Litmus.Type.Number{ 27 | ...> integer: true 28 | ...> }, 29 | ...> "gpa" => %Litmus.Type.Number{ 30 | ...> min: 0, 31 | ...> max: 4 32 | ...> } 33 | ...> } 34 | iex> params = %{"id" => "123", "gpa" => 3.8} 35 | iex> Litmus.validate(params, schema) 36 | {:ok, %{"id" => 123, "gpa" => 3.8}} 37 | iex> params = %{"id" => "123.456", "gpa" => 3.8} 38 | iex> Litmus.validate(params, schema) 39 | {:error, "id must be an integer"} 40 | 41 | iex> schema = %{ 42 | ...> "gpa" => %Litmus.Type.Number{ 43 | ...> default: 4 44 | ...> } 45 | ...> } 46 | iex> Litmus.validate(%{}, schema) 47 | {:ok, %{"gpa" => 4}} 48 | 49 | """ 50 | 51 | defstruct [ 52 | :min, 53 | :max, 54 | default: Litmus.Type.Any.NoDefault, 55 | integer: false, 56 | required: false 57 | ] 58 | 59 | @type t :: %__MODULE__{ 60 | default: any, 61 | min: number | nil, 62 | max: number | nil, 63 | integer: boolean, 64 | required: boolean 65 | } 66 | 67 | alias Litmus.{Default, Required} 68 | 69 | @spec validate_field(t, term, map) :: {:ok, map} | {:error, String.t()} 70 | def validate_field(type, field, data) do 71 | with {:ok, data} <- Required.validate(type, field, data), 72 | {:ok, data} <- convert(type, field, data), 73 | {:ok, data} <- min_validate(type, field, data), 74 | {:ok, data} <- max_validate(type, field, data), 75 | {:ok, data} <- integer_validate(type, field, data) do 76 | {:ok, data} 77 | else 78 | {:ok_not_present, data} -> Default.validate(type, field, data) 79 | {:error, msg} -> {:error, msg} 80 | end 81 | end 82 | 83 | @spec convert(t, term, map) :: {:ok, map} | {:error, String.t()} 84 | defp convert(%__MODULE__{}, field, params) do 85 | cond do 86 | params[field] == nil -> 87 | {:ok, params} 88 | 89 | is_number(params[field]) -> 90 | {:ok, params} 91 | 92 | is_binary(params[field]) && string_to_number(params[field]) -> 93 | modified_value = string_to_number(params[field]) 94 | {:ok, Map.put(params, field, modified_value)} 95 | 96 | true -> 97 | {:error, "#{field} must be a number"} 98 | end 99 | end 100 | 101 | @spec string_to_number(binary) :: number | nil 102 | defp string_to_number(str) do 103 | str = if String.starts_with?(str, "."), do: "0" <> str, else: str 104 | 105 | cond do 106 | int = string_to_integer(str) -> int 107 | float = string_to_float(str) -> float 108 | true -> nil 109 | end 110 | end 111 | 112 | @spec string_to_integer(binary) :: number | nil 113 | defp string_to_integer(str) do 114 | case Integer.parse(str) do 115 | {num, ""} -> num 116 | _ -> nil 117 | end 118 | end 119 | 120 | @spec string_to_float(binary) :: number | nil 121 | defp string_to_float(str) do 122 | case Float.parse(str) do 123 | {num, ""} -> num 124 | _ -> nil 125 | end 126 | end 127 | 128 | @spec integer_validate(t, term, map) :: {:ok, map} | {:error, String.t()} 129 | defp integer_validate(%__MODULE__{integer: false}, _field, params) do 130 | {:ok, params} 131 | end 132 | 133 | defp integer_validate(%__MODULE__{integer: true}, field, params) do 134 | if is_integer(params[field]) do 135 | {:ok, params} 136 | else 137 | {:error, "#{field} must be an integer"} 138 | end 139 | end 140 | 141 | @spec min_validate(t, term, map) :: {:ok, map} | {:error, String.t()} 142 | defp min_validate(%__MODULE__{min: nil}, _field, params) do 143 | {:ok, params} 144 | end 145 | 146 | defp min_validate(%__MODULE__{min: min}, field, params) 147 | when is_number(min) do 148 | if params[field] < min do 149 | {:error, "#{field} must be greater than or equal to #{min}"} 150 | else 151 | {:ok, params} 152 | end 153 | end 154 | 155 | @spec max_validate(t, term, map) :: {:ok, map} | {:error, String.t()} 156 | defp max_validate(%__MODULE__{max: nil}, _field, params) do 157 | {:ok, params} 158 | end 159 | 160 | defp max_validate(%__MODULE__{max: max}, field, params) 161 | when is_number(max) do 162 | if params[field] > max do 163 | {:error, "#{field} must be less than or equal to #{max}"} 164 | else 165 | {:ok, params} 166 | end 167 | end 168 | 169 | defimpl Litmus.Type do 170 | alias Litmus.Type 171 | 172 | @spec validate(Type.t(), term, map) :: {:ok, map} | {:error, String.t()} 173 | def validate(type, field, data), do: Type.Number.validate_field(type, field, data) 174 | end 175 | end 176 | -------------------------------------------------------------------------------- /lib/litmus/type/string.ex: -------------------------------------------------------------------------------- 1 | defmodule Litmus.Type.String do 2 | @moduledoc """ 3 | This type validates and converts values to strings It converts boolean and 4 | number values to strings. 5 | 6 | ## Options 7 | 8 | * `:default` - Setting `:default` will populate a field with the provided 9 | value, assuming that it is not present already. If a field already has a 10 | value present, it will not be altered. 11 | 12 | * `:min_length` - Specifies the minimum number of characters allowed in the 13 | string. Allowed values are non-negative integers. 14 | 15 | * `:max_length` - Specifies the maximum number of characters allowed in the 16 | string. Allowed values are non-negative integers. 17 | 18 | * `:length` - Specifies the exact number of characters allowed in the 19 | string. Allowed values are non-negative integers. 20 | 21 | * `:regex` - Specifies a Regular expression that a string must match. Use 22 | the `Litmus.Type.String.Regex` struct with the options: 23 | 24 | * `:pattern` - A `Regex.t()` to match 25 | * `:error_message` - An error message to use when the pattern does not match 26 | 27 | * `:replace` - Replaces occurences of a pattern with a string. Use the 28 | `Litmus.Type.String.Replace` struct with the options: 29 | 30 | * `:pattern` - A `Regex.t()`, `String.t()`, or compiled pattern to match 31 | * `:replacement` - A `String.t()` to replace 32 | * `:global` - When `true`, all occurences of the pattern are replaced. 33 | When `false`, only the first occurence is replaced. Defaults to `true`. 34 | 35 | * `:required` - Setting `:required` to `true` will cause a validation error 36 | when a field is not present or the value is `nil`. Allowed values for 37 | required are `true` and `false`. The default is `false`. 38 | 39 | * `:trim` - Removes additional whitespace at the front and end of a string. 40 | Allowed values are `true` and `false`. The default is `false`. 41 | 42 | ## Examples 43 | 44 | iex> schema = %{ 45 | ...> "username" => %Litmus.Type.String{ 46 | ...> min_length: 3, 47 | ...> max_length: 10, 48 | ...> trim: true 49 | ...> }, 50 | ...> "password" => %Litmus.Type.String{ 51 | ...> length: 6, 52 | ...> regex: %Litmus.Type.String.Regex{ 53 | ...> pattern: ~r/^[a-zA-Z0-9_]*$/, 54 | ...> error_message: "password must be alphanumeric" 55 | ...> } 56 | ...> } 57 | ...> } 58 | iex> params = %{"username" => " user123 ", "password" => "root01"} 59 | iex> Litmus.validate(params, schema) 60 | {:ok, %{"username" => "user123", "password" => "root01"}} 61 | iex> Litmus.validate(%{"password" => "ro!_@1"}, schema) 62 | {:error, "password must be alphanumeric"} 63 | 64 | iex> schema = %{ 65 | ...> "username" => %Litmus.Type.String{ 66 | ...> replace: %Litmus.Type.String.Replace{ 67 | ...> pattern: ~r/\_/, 68 | ...> replacement: "" 69 | ...> } 70 | ...> } 71 | ...> } 72 | iex> Litmus.validate(%{"username" => "one_two_three"}, schema) 73 | {:ok, %{"username" => "onetwothree"}} 74 | 75 | iex> schema = %{ 76 | ...> "username" => %Litmus.Type.String{ 77 | ...> default: "anonymous" 78 | ...> } 79 | ...> } 80 | iex> Litmus.validate(%{}, schema) 81 | {:ok, %{"username" => "anonymous"}} 82 | 83 | """ 84 | 85 | alias Litmus.{Default, Required} 86 | alias Litmus.Type 87 | 88 | defstruct [ 89 | :min_length, 90 | :max_length, 91 | :length, 92 | default: Litmus.Type.Any.NoDefault, 93 | regex: %Type.String.Regex{}, 94 | replace: %Type.String.Replace{}, 95 | trim: false, 96 | required: false 97 | ] 98 | 99 | @type t :: %__MODULE__{ 100 | default: any, 101 | min_length: non_neg_integer | nil, 102 | max_length: non_neg_integer | nil, 103 | length: non_neg_integer | nil, 104 | regex: Type.String.Regex.t(), 105 | replace: Type.String.Replace.t(), 106 | trim: boolean, 107 | required: boolean 108 | } 109 | 110 | @spec validate_field(t, term, map) :: {:ok, map} | {:error, String.t()} 111 | def validate_field(type, field, data) do 112 | with {:ok, data} <- Required.validate(type, field, data), 113 | {:ok, data} <- convert(type, field, data), 114 | {:ok, data} <- trim(type, field, data), 115 | {:ok, data} <- min_length_validate(type, field, data), 116 | {:ok, data} <- max_length_validate(type, field, data), 117 | {:ok, data} <- length_validate(type, field, data), 118 | {:ok, data} <- regex_validate(type, field, data), 119 | {:ok, data} <- replace(type, field, data) do 120 | {:ok, data} 121 | else 122 | {:ok_not_present, data} -> Default.validate(type, field, data) 123 | {:error, msg} -> {:error, msg} 124 | end 125 | end 126 | 127 | @spec convert(t, term, map) :: {:ok, map} | {:error, String.t()} 128 | defp convert(%__MODULE__{}, field, params) do 129 | cond do 130 | params[field] == nil -> 131 | {:ok, params} 132 | 133 | is_binary(params[field]) -> 134 | {:ok, params} 135 | 136 | is_number(params[field]) or is_boolean(params[field]) -> 137 | {:ok, Map.update!(params, field, &to_string/1)} 138 | 139 | true -> 140 | {:error, "#{field} must be a string"} 141 | end 142 | end 143 | 144 | @spec min_length_validate(t, term, map) :: {:ok, map} | {:error, String.t()} 145 | defp min_length_validate(%__MODULE__{min_length: min_length}, field, params) 146 | when is_integer(min_length) and min_length > 0 do 147 | if params[field] == nil or String.length(params[field]) < min_length do 148 | {:error, "#{field} length must be greater than or equal to #{min_length} characters"} 149 | else 150 | {:ok, params} 151 | end 152 | end 153 | 154 | defp min_length_validate(%__MODULE__{}, _field, params) do 155 | {:ok, params} 156 | end 157 | 158 | @spec max_length_validate(t, term, map) :: {:ok, map} | {:error, String.t()} 159 | defp max_length_validate(%__MODULE__{max_length: nil}, _field, params) do 160 | {:ok, params} 161 | end 162 | 163 | defp max_length_validate(%__MODULE__{max_length: max_length}, field, params) 164 | when is_integer(max_length) and max_length >= 0 do 165 | if Map.get(params, field) && String.length(params[field]) > max_length do 166 | {:error, "#{field} length must be less than or equal to #{max_length} characters"} 167 | else 168 | {:ok, params} 169 | end 170 | end 171 | 172 | @spec length_validate(t, term, map) :: {:ok, map} | {:error, String.t()} 173 | defp length_validate(%__MODULE__{length: nil}, _field, params) do 174 | {:ok, params} 175 | end 176 | 177 | defp length_validate(%__MODULE__{length: 0}, field, params) do 178 | if params[field] in [nil, ""] do 179 | {:ok, params} 180 | else 181 | {:error, "#{field} length must be 0 characters"} 182 | end 183 | end 184 | 185 | defp length_validate(%__MODULE__{length: len}, field, params) when is_integer(len) do 186 | if params[field] == nil || String.length(params[field]) != len do 187 | {:error, "#{field} length must be #{len} characters"} 188 | else 189 | {:ok, params} 190 | end 191 | end 192 | 193 | @spec replace(t, term, map) :: {:ok, map} 194 | defp replace(%__MODULE__{replace: %__MODULE__.Replace{pattern: nil}}, _field, params) do 195 | {:ok, params} 196 | end 197 | 198 | defp replace(%__MODULE__{replace: replace}, field, params) do 199 | new_string = 200 | String.replace(params[field], replace.pattern, replace.replacement, global: replace.global) 201 | 202 | {:ok, Map.put(params, field, new_string)} 203 | end 204 | 205 | @spec regex_validate(t, term, map) :: {:ok, map} | {:error, String.t()} 206 | defp regex_validate(%__MODULE__{regex: %__MODULE__.Regex{pattern: nil}}, _field, params) do 207 | {:ok, params} 208 | end 209 | 210 | defp regex_validate(%__MODULE__{regex: regex}, field, params) do 211 | if params[field] == nil or !Regex.match?(regex.pattern, params[field]) do 212 | error_message = regex.error_message || "#{field} must be in a valid format" 213 | {:error, error_message} 214 | else 215 | {:ok, params} 216 | end 217 | end 218 | 219 | @spec trim(t, term, map) :: {:ok, map} 220 | defp trim(%__MODULE__{trim: true}, field, params) do 221 | if Map.get(params, field) do 222 | trimmed_value = String.trim(params[field]) 223 | trimmed_params = Map.put(params, field, trimmed_value) 224 | {:ok, trimmed_params} 225 | else 226 | {:ok, params} 227 | end 228 | end 229 | 230 | defp trim(%__MODULE__{trim: false}, _field, params) do 231 | {:ok, params} 232 | end 233 | 234 | defimpl Litmus.Type do 235 | alias Litmus.Type 236 | 237 | @spec validate(Type.t(), term, map) :: {:ok, map} | {:error, String.t()} 238 | def validate(type, field, data), do: Type.String.validate_field(type, field, data) 239 | end 240 | end 241 | -------------------------------------------------------------------------------- /lib/litmus/type/string/regex.ex: -------------------------------------------------------------------------------- 1 | defmodule Litmus.Type.String.Regex do 2 | @moduledoc false 3 | 4 | defstruct [:pattern, :error_message] 5 | 6 | @type t :: %__MODULE__{ 7 | pattern: Regex.t() | nil, 8 | error_message: String.t() | nil 9 | } 10 | end 11 | -------------------------------------------------------------------------------- /lib/litmus/type/string/replace.ex: -------------------------------------------------------------------------------- 1 | defmodule Litmus.Type.String.Replace do 2 | @moduledoc false 3 | 4 | defstruct [ 5 | :pattern, 6 | :replacement, 7 | global: true 8 | ] 9 | 10 | @type t :: %__MODULE__{ 11 | pattern: String.pattern() | Regex.t() | nil, 12 | replacement: String.t() | nil, 13 | global: boolean 14 | } 15 | end 16 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Litmus.MixProject do 2 | use Mix.Project 3 | 4 | @github_url "https://github.com/lob/litmus" 5 | 6 | def project do 7 | [ 8 | app: :litmus, 9 | version: "1.0.2", 10 | elixir: "~> 1.12", 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps(), 13 | dialyzer: [ 14 | plt_add_deps: :apps_direct, 15 | plt_add_apps: [:plug], 16 | ignore_warnings: ".dialyzer_ignore" 17 | ], 18 | 19 | # Docs 20 | name: "litmus", 21 | description: "Data validation in elixir", 22 | source_url: @github_url, 23 | homepage_url: @github_url, 24 | docs: [ 25 | main: "readme", 26 | extras: ["README.md"] 27 | ], 28 | package: [ 29 | files: ~w(mix.exs lib LICENSE* README.md CHANGELOG.md), 30 | maintainers: ["Lob"], 31 | licenses: ["MIT"], 32 | links: %{ 33 | "GitHub" => @github_url 34 | } 35 | ], 36 | 37 | # ExCoveralls 38 | test_coverage: [tool: ExCoveralls], 39 | preferred_cli_env: [ 40 | coveralls: :test, 41 | "coveralls.travis": :test, 42 | "coveralls.html": :test 43 | ] 44 | ] 45 | end 46 | 47 | # Run "mix help compile.app" to learn about applications. 48 | def application do 49 | [ 50 | extra_applications: [:logger] 51 | ] 52 | end 53 | 54 | # Run "mix help deps" to learn about dependencies. 55 | defp deps do 56 | [ 57 | {:credo, "~> 1.6.4", only: [:dev, :test], runtime: false}, 58 | {:dialyxir, "~> 1.1.0", only: [:dev, :test], runtime: false}, 59 | {:ex_doc, "~> 0.28.2", only: :dev, runtime: false}, 60 | {:excoveralls, "~> 0.14.4", only: :test}, 61 | {:plug, "~> 1.13.4", optional: true} 62 | ] 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 3 | "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, 4 | "credo": {:hex, :credo, "1.6.4", "ddd474afb6e8c240313f3a7b0d025cc3213f0d171879429bf8535d7021d9ad78", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "c28f910b61e1ff829bffa056ef7293a8db50e87f2c57a9b5c3f57eee124536b7"}, 5 | "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, 6 | "earmark": {:hex, :earmark, "1.4.5", "62ffd3bd7722fb7a7b1ecd2419ea0b458c356e7168c1f5d65caf09b4fbdd13c8", [:mix], [], "hexpm", "b7d0e6263d83dc27141a523467799a685965bf8b13b6743413f19a7079843f4f"}, 7 | "earmark_parser": {:hex, :earmark_parser, "1.4.23", "1d5f22a2802160fd454404fbf5e8f5d14cd8eb727c63701397b72d8c35267e69", [:mix], [], "hexpm", "2ec13bf14b2f4bbb4a15480970e295eede8bb01087fad6ceca27b724ab8e9d18"}, 8 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 9 | "ex_doc": {:hex, :ex_doc, "0.28.2", "e031c7d1a9fc40959da7bf89e2dc269ddc5de631f9bd0e326cbddf7d8085a9da", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "51ee866993ffbd0e41c084a7677c570d0fc50cb85c6b5e76f8d936d9587fa719"}, 10 | "excoveralls": {:hex, :excoveralls, "0.14.4", "295498f1ae47bdc6dce59af9a585c381e1aefc63298d48172efaaa90c3d251db", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e3ab02f2df4c1c7a519728a6f0a747e71d7d6e846020aae338173619217931c1"}, 11 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 12 | "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, 13 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 14 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, 15 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 16 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, 17 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 18 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 19 | "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, 20 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 21 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 22 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 23 | "plug": {:hex, :plug, "1.13.4", "addb6e125347226e3b11489e23d22a60f7ab74786befb86c14f94fb5f23ca9a4", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "06114c1f2a334212fe3ae567dbb3b1d29fd492c1a09783d52f3d489c1a6f4cf2"}, 24 | "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, 25 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, 26 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 27 | "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, 28 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 29 | } 30 | -------------------------------------------------------------------------------- /test/litmus/default_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Litmus.DefaultTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Litmus.Default 5 | alias Litmus.Type 6 | 7 | describe "validate/3" do 8 | test "returns params with default value populated if field not present" do 9 | type = %Type.Any{ 10 | default: "12345" 11 | } 12 | 13 | assert Default.validate(type, "id", %{}) == {:ok, %{"id" => "12345"}} 14 | end 15 | 16 | test "returns a default value of nil" do 17 | type = %Type.Any{ 18 | default: nil 19 | } 20 | 21 | assert Default.validate(type, "id", %{}) == {:ok, %{"id" => nil}} 22 | end 23 | 24 | test "returns unaltered params if field is present" do 25 | params = %{"id" => "12345"} 26 | 27 | type = %Type.Any{ 28 | default: "67890" 29 | } 30 | 31 | assert Default.validate(type, "id", params) == {:ok, params} 32 | end 33 | 34 | test "does not populate values if default is not present on type" do 35 | type = %Type.Any{} 36 | 37 | assert Default.validate(type, "id", %{}) == {:ok, %{}} 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/litmus/plug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Litmus.PlugTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | 5 | describe "init/1" do 6 | test "returns input options" do 7 | assert Litmus.Plug.init([]) == [] 8 | end 9 | end 10 | 11 | defmodule TestRouter do 12 | use Plug.Router 13 | 14 | plug(Plug.Parsers, parsers: [:urlencoded, :multipart]) 15 | 16 | plug(:match) 17 | 18 | plug(Litmus.Plug, on_error: &__MODULE__.on_error/2) 19 | 20 | plug(:dispatch) 21 | 22 | @schema %{ 23 | "id" => %Litmus.Type.Number{ 24 | required: true 25 | } 26 | } 27 | 28 | get "/test", private: %{litmus_query: @schema} do 29 | Plug.Conn.send_resp(conn, 200, "items") 30 | end 31 | 32 | post "/test", private: %{litmus_body: @schema} do 33 | Plug.Conn.send_resp(conn, 200, "items") 34 | end 35 | 36 | def on_error(conn, error_message) do 37 | conn 38 | |> Plug.Conn.send_resp(400, error_message) 39 | |> Plug.Conn.halt() 40 | end 41 | end 42 | 43 | test "applies validation with valid query params" do 44 | conn = 45 | :get 46 | |> Plug.Test.conn("/test?id=123") 47 | |> TestRouter.call([]) 48 | 49 | assert conn.state == :sent 50 | assert conn.status == 200 51 | assert conn.query_params == %{"id" => 123} 52 | end 53 | 54 | test "calls the on_error function with invalid query params" do 55 | conn = 56 | :get 57 | |> Plug.Test.conn("/test?id=hello") 58 | |> TestRouter.call([]) 59 | 60 | assert conn.state == :sent 61 | assert conn.status == 400 62 | assert conn.resp_body == "id must be a number" 63 | end 64 | 65 | test "applies validation with valid body params" do 66 | conn = 67 | :post 68 | |> Plug.Test.conn("/test", %{"id" => "123"}) 69 | |> TestRouter.call([]) 70 | 71 | assert conn.state == :sent 72 | assert conn.status == 200 73 | assert conn.body_params == %{"id" => 123} 74 | end 75 | 76 | test "calls the on_error function with invalid body params" do 77 | conn = 78 | :post 79 | |> Plug.Test.conn("/test", %{"id" => "hello"}) 80 | |> TestRouter.call([]) 81 | 82 | assert conn.state == :sent 83 | assert conn.status == 400 84 | assert conn.resp_body == "id must be a number" 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /test/litmus/required_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Litmus.RequiredTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Litmus.Required 5 | alias Litmus.Type 6 | 7 | describe "validate/3" do 8 | test "returns ok when field is required and is present in params" do 9 | params = %{"id" => "1"} 10 | 11 | type = %Type.Any{ 12 | required: true 13 | } 14 | 15 | assert Required.validate(type, "id", params) == {:ok, params} 16 | end 17 | 18 | test "returns error when field is required and not present in params" do 19 | field = "id" 20 | params = %{} 21 | 22 | type = %Type.Any{ 23 | required: true 24 | } 25 | 26 | assert Required.validate(type, field, params) == {:error, "#{field} is required"} 27 | end 28 | 29 | test "returns error when field is required and the value is nil params" do 30 | field = "id" 31 | params = %{"id" => nil} 32 | 33 | type = %Type.Any{ 34 | required: true 35 | } 36 | 37 | assert Required.validate(type, field, params) == {:error, "#{field} is required"} 38 | end 39 | 40 | test "returns ok when field is not required and is not present in params" do 41 | field = "id" 42 | params = %{} 43 | 44 | type = %Type.Any{ 45 | required: false 46 | } 47 | 48 | assert Required.validate(type, field, params) == {:ok_not_present, params} 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/litmus/type/any_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Litmus.Type.AnyTest do 2 | use ExUnit.Case, async: true 3 | doctest Litmus.Type.Any 4 | 5 | alias Litmus.Type 6 | 7 | describe "validate_field/3" do 8 | test "validates property values of data based on their Any schema definition in Type.Any module" do 9 | field = "id" 10 | data = %{"id" => "1"} 11 | 12 | type = %Type.Any{ 13 | required: true 14 | } 15 | 16 | assert Type.Any.validate_field(type, field, data) == {:ok, data} 17 | end 18 | 19 | test "does not error if the field is not provided and not required" do 20 | field = "id" 21 | data = %{} 22 | 23 | type = %Type.Any{} 24 | 25 | assert Type.Any.validate_field(type, field, data) == {:ok, data} 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/litmus/type/boolean_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Litmus.Type.BooleanTest do 2 | use ExUnit.Case, async: true 3 | doctest Litmus.Type.Boolean 4 | 5 | alias Litmus.Type 6 | 7 | describe "validate_field/3" do 8 | test "validates Type.Boolean fields in a schema" do 9 | field = "id_given" 10 | data = %{"id_given" => true} 11 | 12 | type = %Type.Boolean{ 13 | required: true 14 | } 15 | 16 | assert Type.Boolean.validate_field(type, field, data) == {:ok, data} 17 | end 18 | 19 | test "does not convert nil to a boolean" do 20 | field = "id_given" 21 | data = %{"id_given" => nil} 22 | 23 | type = %Type.Boolean{} 24 | 25 | assert Type.Boolean.validate_field(type, field, data) == {:ok, data} 26 | end 27 | 28 | test "does not error if the field is not provided and not required" do 29 | field = "id" 30 | data = %{} 31 | 32 | type = %Type.Boolean{ 33 | truthy: [1, "One"] 34 | } 35 | 36 | assert Type.Boolean.validate_field(type, field, data) == {:ok, data} 37 | end 38 | end 39 | 40 | describe "truthy validation" do 41 | test "returns :ok with value converted to true when field is in additional truthy values that are considered to be valid booleans" do 42 | data = %{"id_given" => 1} 43 | case_data = %{"id_given" => "onE"} 44 | modified_data = %{"id_given" => true} 45 | 46 | schema = %{ 47 | "id_given" => %Litmus.Type.Boolean{ 48 | truthy: [1, "One"] 49 | }, 50 | "new_user" => %Litmus.Type.Boolean{} 51 | } 52 | 53 | assert Litmus.validate(data, schema) == {:ok, modified_data} 54 | assert Litmus.validate(case_data, schema) == {:ok, modified_data} 55 | end 56 | 57 | test "errors when field is not in additional truthy values that are considered to be valid booleans" do 58 | field = "id_given" 59 | data = %{"id_given" => "1"} 60 | 61 | schema = %{ 62 | "id_given" => %Litmus.Type.Boolean{ 63 | truthy: [1] 64 | } 65 | } 66 | 67 | assert Litmus.validate(data, schema) == {:error, "#{field} must be a boolean"} 68 | end 69 | end 70 | 71 | describe "falsy validation" do 72 | test "returns :ok with value converted to false when field is in additional falsy values that are considered to be valid booleans" do 73 | data = %{"id_given" => 0} 74 | modified_data = %{"id_given" => false} 75 | 76 | schema = %{ 77 | "id_given" => %Litmus.Type.Boolean{ 78 | falsy: [0] 79 | } 80 | } 81 | 82 | assert Litmus.validate(data, schema) == {:ok, modified_data} 83 | end 84 | 85 | test "errors when field is not in additional falsy values that are considered to be valid booleans" do 86 | field = "id_given" 87 | data = %{"id_given" => "0"} 88 | 89 | schema = %{ 90 | "id_given" => %Litmus.Type.Boolean{ 91 | falsy: [0] 92 | } 93 | } 94 | 95 | assert Litmus.validate(data, schema) == {:error, "#{field} must be a boolean"} 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /test/litmus/type/date_time_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Litmus.Type.DateTimeTest do 2 | use ExUnit.Case, async: true 3 | doctest Litmus.Type.DateTime 4 | 5 | alias Litmus.Type 6 | 7 | @field "start_date" 8 | 9 | describe "validate_field/3" do 10 | test "converts a string to a datetime" do 11 | data = %{@field => "1990-05-01T06:32:00Z"} 12 | 13 | {:ok, expected_date_time, _} = DateTime.from_iso8601(data[@field]) 14 | expected_data = %{@field => expected_date_time} 15 | 16 | type = %Type.DateTime{ 17 | required: true 18 | } 19 | 20 | assert Type.DateTime.validate_field(type, @field, data) == {:ok, expected_data} 21 | end 22 | 23 | test "does not alter nil" do 24 | data = %{@field => nil} 25 | 26 | type = %Type.DateTime{} 27 | 28 | assert Type.DateTime.validate_field(type, @field, data) == {:ok, data} 29 | end 30 | 31 | test "allows DateTimes" do 32 | {:ok, datetime, _} = DateTime.from_iso8601("1999-01-05T05:00:00Z") 33 | data = %{@field => datetime} 34 | 35 | type = %Type.DateTime{ 36 | required: true 37 | } 38 | 39 | assert Type.DateTime.validate_field(type, @field, data) == {:ok, data} 40 | end 41 | 42 | test "errors when required datetime is not provided" do 43 | data = %{} 44 | 45 | type = %Type.DateTime{ 46 | required: true 47 | } 48 | 49 | assert Type.DateTime.validate_field(type, @field, data) == 50 | {:error, "start_date is required"} 51 | end 52 | 53 | test "errors when the value is not a string" do 54 | data = %{@field => 1234} 55 | 56 | type = %Type.DateTime{ 57 | required: true 58 | } 59 | 60 | assert Type.DateTime.validate_field(type, @field, data) == 61 | {:error, "start_date must be a valid ISO-8601 datetime"} 62 | end 63 | 64 | test "errors when the value is not in an ISO-8601 datetime with timezone format" do 65 | data = %{@field => "2018-06-01"} 66 | 67 | type = %Type.DateTime{ 68 | required: true 69 | } 70 | 71 | assert Type.DateTime.validate_field(type, @field, data) == 72 | {:error, "start_date must be a valid ISO-8601 datetime"} 73 | end 74 | 75 | test "does not error if the field is not provided and not required" do 76 | field = "id" 77 | data = %{} 78 | 79 | type = %Type.DateTime{} 80 | 81 | assert Type.DateTime.validate_field(type, field, data) == {:ok, data} 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /test/litmus/type/list_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Litmus.Type.ListTest do 2 | use ExUnit.Case, async: true 3 | doctest Litmus.Type.List 4 | 5 | alias Litmus.Type 6 | 7 | describe "validate_field/3" do 8 | test "validates property values of data based on their List schema definition in Type.List module" do 9 | field = "id" 10 | data = %{"id" => ["1"]} 11 | 12 | type = %Type.List{ 13 | required: true 14 | } 15 | 16 | assert Type.List.validate_field(type, field, data) == {:ok, data} 17 | end 18 | 19 | test "does not convert nil to a list" do 20 | field = "ids" 21 | data = %{"ids" => nil} 22 | 23 | type = %Type.List{} 24 | 25 | assert Type.List.validate_field(type, field, data) == {:ok, data} 26 | end 27 | 28 | test "does not error if the field is not provided and not required" do 29 | field = "ids" 30 | data = %{} 31 | 32 | type = %Type.List{ 33 | length: 3, 34 | type: :boolean, 35 | unique: true 36 | } 37 | 38 | assert Type.List.validate_field(type, field, data) == {:ok, data} 39 | end 40 | end 41 | 42 | describe "check if field is list" do 43 | test "returns :ok with params if field is a list" do 44 | data = %{"id" => [1, 2, 3]} 45 | 46 | schema = %{ 47 | "id" => %Litmus.Type.List{ 48 | required: true 49 | } 50 | } 51 | 52 | assert Litmus.validate(data, schema) == {:ok, data} 53 | end 54 | 55 | test "errors if field is not a list" do 56 | data = %{"id" => "1, 2, 3"} 57 | 58 | schema = %{ 59 | "id" => %Litmus.Type.List{ 60 | required: true 61 | } 62 | } 63 | 64 | assert Litmus.validate(data, schema) == {:error, "id must be a list"} 65 | end 66 | end 67 | 68 | describe "minimum length validation" do 69 | test "returns :ok when field list length is more than or equal to min_length" do 70 | data = %{"id" => [1, 2, 3]} 71 | 72 | schema = %{ 73 | "id" => %Litmus.Type.List{ 74 | required: true, 75 | min_length: 3 76 | } 77 | } 78 | 79 | assert Litmus.validate(data, schema) == {:ok, data} 80 | end 81 | 82 | test "errors when field list length is less than min_length" do 83 | data = %{"id" => [1, 2]} 84 | 85 | schema = %{ 86 | "id" => %Litmus.Type.List{ 87 | required: true, 88 | min_length: 3 89 | } 90 | } 91 | 92 | assert Litmus.validate(data, schema) == {:error, "id must not be below length of 3"} 93 | end 94 | end 95 | 96 | describe "maximum length validation" do 97 | test "returns :ok when field list length is less than or equal to max_length" do 98 | data = %{"id" => [1, 2, 3]} 99 | 100 | schema = %{ 101 | "id" => %Litmus.Type.List{ 102 | required: true, 103 | max_length: 3 104 | } 105 | } 106 | 107 | assert Litmus.validate(data, schema) == {:ok, data} 108 | end 109 | 110 | test "errors when field list length is more than max_length" do 111 | data = %{"id" => [1, 2, 3, 4]} 112 | 113 | schema = %{ 114 | "id" => %Litmus.Type.List{ 115 | required: true, 116 | max_length: 3 117 | } 118 | } 119 | 120 | assert Litmus.validate(data, schema) == {:error, "id must not exceed length of 3"} 121 | end 122 | end 123 | 124 | describe "exact length validation" do 125 | test "returns :ok when field list length is equal to length" do 126 | data = %{"id" => [1, 2, 3]} 127 | 128 | schema = %{ 129 | "id" => %Litmus.Type.List{ 130 | required: true, 131 | length: 3 132 | } 133 | } 134 | 135 | assert Litmus.validate(data, schema) == {:ok, data} 136 | end 137 | 138 | test "errors when field list length is not equal to length" do 139 | data = %{"id" => [1, 2]} 140 | 141 | schema = %{ 142 | "id" => %Litmus.Type.List{ 143 | required: true, 144 | length: 3 145 | } 146 | } 147 | 148 | assert Litmus.validate(data, schema) == {:error, "id length must be of 3 length"} 149 | end 150 | end 151 | 152 | describe "list element type validation" do 153 | test "returns :ok with params if field elements are of any type if no type specified" do 154 | data = %{"id" => [1, "2", 3]} 155 | 156 | schema = %{ 157 | "id" => %Litmus.Type.List{ 158 | required: true 159 | } 160 | } 161 | 162 | assert Litmus.validate(data, schema) == {:ok, data} 163 | end 164 | 165 | test "returns :ok with params if field elements are of the type specified" do 166 | data = %{ 167 | "id_atom" => [:a, :b], 168 | "id_boolean" => [true, false], 169 | "id_number" => [1, 2], 170 | "id_string" => ["a", "b"], 171 | "id_any" => [1, "a"] 172 | } 173 | 174 | schema = %{ 175 | "id_atom" => %Litmus.Type.List{ 176 | required: true, 177 | type: :atom 178 | }, 179 | "id_boolean" => %Litmus.Type.List{ 180 | required: true, 181 | type: :boolean 182 | }, 183 | "id_number" => %Litmus.Type.List{ 184 | required: true, 185 | type: :number 186 | }, 187 | "id_string" => %Litmus.Type.List{ 188 | required: true, 189 | type: :string 190 | }, 191 | "id_any" => %Litmus.Type.List{} 192 | } 193 | 194 | assert Litmus.validate(data, schema) == {:ok, data} 195 | end 196 | 197 | test "errors if field elements are not of the type specified" do 198 | data = %{"id" => [1, 2, "3", :a, true]} 199 | 200 | schema_atom = %{"id" => %Litmus.Type.List{type: :atom}} 201 | schema_boolean = %{"id" => %Litmus.Type.List{type: :boolean}} 202 | schema_number = %{"id" => %Litmus.Type.List{type: :number}} 203 | schema_string = %{"id" => %Litmus.Type.List{type: :string}} 204 | 205 | assert Litmus.validate(data, schema_atom) == {:error, "id must be a list of atoms"} 206 | assert Litmus.validate(data, schema_boolean) == {:error, "id must be a list of boolean"} 207 | assert Litmus.validate(data, schema_number) == {:error, "id must be a list of numbers"} 208 | assert Litmus.validate(data, schema_string) == {:error, "id must be a list of strings"} 209 | end 210 | end 211 | 212 | describe "uniqueness validation" do 213 | test "returns :ok when values are unique" do 214 | data = %{"id" => [1, 2, 3]} 215 | 216 | schema = %{ 217 | "id" => %Litmus.Type.List{ 218 | unique: true 219 | } 220 | } 221 | 222 | assert Litmus.validate(data, schema) == {:ok, data} 223 | end 224 | 225 | test "errors when values are not unique" do 226 | data = %{"id" => [1, 2, 3, 2]} 227 | 228 | schema = %{ 229 | "id" => %Litmus.Type.List{ 230 | unique: true 231 | } 232 | } 233 | 234 | assert Litmus.validate(data, schema) == {:error, "id cannot contain duplicate values"} 235 | end 236 | 237 | test "returns :ok when disabled" do 238 | data = %{"id" => [1, 2, 3, 1]} 239 | 240 | schema = %{ 241 | "id" => %Litmus.Type.List{ 242 | unique: false 243 | } 244 | } 245 | 246 | assert Litmus.validate(data, schema) == {:ok, data} 247 | end 248 | end 249 | end 250 | -------------------------------------------------------------------------------- /test/litmus/type/number_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Litmus.Type.NumberTest do 2 | use ExUnit.Case, async: true 3 | doctest Litmus.Type.Number 4 | 5 | alias Litmus.Type 6 | 7 | describe "validate_field/3" do 8 | test "validates Type.Number fields in a schema" do 9 | field = "id" 10 | data = %{"id" => 1} 11 | 12 | type = %Type.Number{ 13 | required: true 14 | } 15 | 16 | assert Type.Number.validate_field(type, field, data) == {:ok, data} 17 | end 18 | 19 | test "does not error if the field is not provided and not required" do 20 | field = "id" 21 | data = %{} 22 | 23 | type = %Type.Number{ 24 | min: 3 25 | } 26 | 27 | assert Type.Number.validate_field(type, field, data) == {:ok, data} 28 | end 29 | end 30 | 31 | describe "convert string to number" do 32 | test "returns :ok with modified value" do 33 | float_data = %{"id" => ".6"} 34 | integer_data = %{"id" => "6"} 35 | modified_float_data = %{"id" => 0.6} 36 | modified_integer_data = %{"id" => 6} 37 | 38 | schema = %{ 39 | "id" => %Litmus.Type.Number{} 40 | } 41 | 42 | assert Litmus.validate(integer_data, schema) == {:ok, modified_integer_data} 43 | assert Litmus.validate(float_data, schema) == {:ok, modified_float_data} 44 | end 45 | 46 | test "errors if field type is neither number or stringified number" do 47 | invalid_number = %{"id" => "1.a"} 48 | boolean_data = %{"id" => true} 49 | 50 | schema = %{ 51 | "id" => %Litmus.Type.Number{} 52 | } 53 | 54 | assert Litmus.validate(invalid_number, schema) == {:error, "id must be a number"} 55 | assert Litmus.validate(boolean_data, schema) == {:error, "id must be a number"} 56 | end 57 | 58 | test "does not convert nil to a number" do 59 | field = "id" 60 | data = %{"id" => nil} 61 | 62 | type = %Type.Number{} 63 | 64 | assert Type.Number.validate_field(type, field, data) == {:ok, data} 65 | end 66 | end 67 | 68 | describe "minimum validation" do 69 | test "returns :ok when field is more than or equal to min value" do 70 | data = %{"id" => 6} 71 | 72 | schema = %{ 73 | "id" => %Litmus.Type.Number{ 74 | required: true, 75 | min: 3 76 | } 77 | } 78 | 79 | assert Litmus.validate(data, schema) == {:ok, data} 80 | end 81 | 82 | test "errors when field is less than min" do 83 | data = %{"id" => 1} 84 | 85 | schema = %{ 86 | "id" => %Litmus.Type.Number{ 87 | required: true, 88 | min: 3 89 | } 90 | } 91 | 92 | assert Litmus.validate(data, schema) == {:error, "id must be greater than or equal to 3"} 93 | end 94 | end 95 | 96 | describe "maximum validation" do 97 | test "returns :ok when field is less than or equal to max" do 98 | data = %{"id" => 1} 99 | 100 | schema = %{ 101 | "id" => %Litmus.Type.Number{ 102 | required: true, 103 | max: 3 104 | } 105 | } 106 | 107 | assert Litmus.validate(data, schema) == {:ok, data} 108 | end 109 | 110 | test "errors when field is more than max" do 111 | data = %{"id" => 6} 112 | 113 | schema = %{ 114 | "id" => %Litmus.Type.Number{ 115 | required: true, 116 | max: 3 117 | } 118 | } 119 | 120 | assert Litmus.validate(data, schema) == {:error, "id must be less than or equal to 3"} 121 | end 122 | end 123 | 124 | describe "integer type validation" do 125 | test "returns :ok when field is an integer and the schema property integer is set to true" do 126 | data = %{"id" => 1} 127 | 128 | schema = %{ 129 | "id" => %Litmus.Type.Number{ 130 | required: true, 131 | integer: true 132 | } 133 | } 134 | 135 | assert Litmus.validate(data, schema) == {:ok, data} 136 | end 137 | 138 | test "errors when field is a float and the schema property integer is set to true" do 139 | data = %{"id" => 1.6} 140 | 141 | schema = %{ 142 | "id" => %Litmus.Type.Number{ 143 | required: true, 144 | integer: true 145 | } 146 | } 147 | 148 | assert Litmus.validate(data, schema) == {:error, "id must be an integer"} 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /test/litmus/type/string_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Litmus.Type.StringTest do 2 | use ExUnit.Case, async: true 3 | doctest Litmus.Type.String 4 | 5 | alias Litmus.Type 6 | 7 | describe "validate_field/3" do 8 | test "validates property values of data based on their String schema definition in Type.String module" do 9 | field = "id" 10 | data = %{"id" => "1"} 11 | 12 | type = %Type.String{ 13 | required: true 14 | } 15 | 16 | assert Type.String.validate_field(type, field, data) == {:ok, data} 17 | end 18 | end 19 | 20 | describe "minimum length validation" do 21 | test "returns :ok when length of field is more than or equal to min_length" do 22 | min_length = 3 23 | data = %{"id" => "abc"} 24 | 25 | schema = %{ 26 | "id" => %Litmus.Type.String{ 27 | required: true, 28 | min_length: min_length 29 | } 30 | } 31 | 32 | assert Litmus.validate(data, schema) == {:ok, data} 33 | end 34 | 35 | test "returns :ok when the value is nil and min_length is 0" do 36 | data = %{"id" => nil} 37 | 38 | schema = %{ 39 | "id" => %Litmus.Type.String{ 40 | min_length: 0 41 | } 42 | } 43 | 44 | assert Litmus.validate(data, schema) == {:ok, data} 45 | end 46 | 47 | test "returns :ok when the value is an empty string and min_length is 0" do 48 | data = %{"id" => ""} 49 | 50 | schema = %{ 51 | "id" => %Litmus.Type.String{ 52 | min_length: 0 53 | } 54 | } 55 | 56 | assert Litmus.validate(data, schema) == {:ok, data} 57 | end 58 | 59 | test "errors when length of field is less than min_length" do 60 | min_length = 3 61 | field = "id" 62 | data = %{"id" => "ab"} 63 | 64 | schema = %{ 65 | "id" => %Litmus.Type.String{ 66 | required: true, 67 | min_length: min_length 68 | } 69 | } 70 | 71 | assert Litmus.validate(data, schema) == 72 | {:error, 73 | "#{field} length must be greater than or equal to #{min_length} characters"} 74 | end 75 | 76 | test "errors when value is nil and min_length is greater than 0" do 77 | min_length = 3 78 | field = "id" 79 | data = %{"id" => nil} 80 | 81 | schema = %{ 82 | "id" => %Litmus.Type.String{ 83 | min_length: min_length 84 | } 85 | } 86 | 87 | assert Litmus.validate(data, schema) == 88 | {:error, 89 | "#{field} length must be greater than or equal to #{min_length} characters"} 90 | end 91 | 92 | test "does not error if the field is not provided and not required" do 93 | field = "id" 94 | data = %{} 95 | 96 | type = %Type.String{ 97 | min_length: 3 98 | } 99 | 100 | assert Type.String.validate_field(type, field, data) == {:ok, data} 101 | end 102 | end 103 | 104 | describe "maximum length validation" do 105 | test "returns :ok when length of field is less than or equal to max_length" do 106 | max_length = 3 107 | data = %{"id" => "ab"} 108 | 109 | schema = %{ 110 | "id" => %Litmus.Type.String{ 111 | required: true, 112 | max_length: max_length 113 | } 114 | } 115 | 116 | assert Litmus.validate(data, schema) == {:ok, data} 117 | end 118 | 119 | test "returns :ok when the value is nil" do 120 | data = %{"id" => nil} 121 | 122 | schema = %{ 123 | "id" => %Litmus.Type.String{ 124 | max_length: 10 125 | } 126 | } 127 | 128 | assert Litmus.validate(data, schema) == {:ok, data} 129 | end 130 | 131 | test "returns :ok when the value is nil and the max length is 0" do 132 | data = %{"id" => nil} 133 | 134 | schema = %{ 135 | "id" => %Litmus.Type.String{ 136 | max_length: 0 137 | } 138 | } 139 | 140 | assert Litmus.validate(data, schema) == {:ok, data} 141 | end 142 | 143 | test "returns :ok when the value is an empty string and the max length is 0" do 144 | data = %{"id" => ""} 145 | 146 | schema = %{ 147 | "id" => %Litmus.Type.String{ 148 | max_length: 0 149 | } 150 | } 151 | 152 | assert Litmus.validate(data, schema) == {:ok, data} 153 | end 154 | 155 | test "errors when length of field is more than max_length" do 156 | max_length = 3 157 | field = "id" 158 | data = %{"id" => "abcd"} 159 | 160 | schema = %{ 161 | "id" => %Litmus.Type.String{ 162 | required: true, 163 | max_length: max_length 164 | } 165 | } 166 | 167 | assert Litmus.validate(data, schema) == 168 | {:error, "#{field} length must be less than or equal to #{max_length} characters"} 169 | end 170 | end 171 | 172 | describe "exact length validation" do 173 | test "returns :ok when length of field is equal to length" do 174 | length = 3 175 | data = %{"id" => "abc"} 176 | 177 | schema = %{ 178 | "id" => %Litmus.Type.String{ 179 | required: true, 180 | length: length 181 | } 182 | } 183 | 184 | assert Litmus.validate(data, schema) == {:ok, data} 185 | end 186 | 187 | test "returns :ok when the value is an empty string and the length is 0" do 188 | length = 0 189 | data = %{"id" => ""} 190 | 191 | schema = %{ 192 | "id" => %Litmus.Type.String{ 193 | length: length 194 | } 195 | } 196 | 197 | assert Litmus.validate(data, schema) == {:ok, data} 198 | end 199 | 200 | test "returns :ok when the value is nil and the length is 0" do 201 | length = 0 202 | data = %{"id" => nil} 203 | 204 | schema = %{ 205 | "id" => %Litmus.Type.String{ 206 | length: length 207 | } 208 | } 209 | 210 | assert Litmus.validate(data, schema) == {:ok, data} 211 | end 212 | 213 | test "errors when length of field is not equal to length" do 214 | length = 3 215 | field = "id" 216 | data = %{"id" => "abcd"} 217 | 218 | schema = %{ 219 | "id" => %Litmus.Type.String{ 220 | required: true, 221 | length: length 222 | } 223 | } 224 | 225 | assert Litmus.validate(data, schema) == 226 | {:error, "#{field} length must be #{length} characters"} 227 | end 228 | 229 | test "errors when the value is nil and length is greater than 0" do 230 | length = 3 231 | field = "id" 232 | data = %{"id" => nil} 233 | 234 | schema = %{ 235 | "id" => %Litmus.Type.String{ 236 | length: length 237 | } 238 | } 239 | 240 | assert Litmus.validate(data, schema) == 241 | {:error, "#{field} length must be #{length} characters"} 242 | end 243 | 244 | test "errors when the length is 0 and the value is not empty or nil" do 245 | length = 0 246 | field = "id" 247 | data = %{"id" => "a"} 248 | 249 | schema = %{ 250 | "id" => %Litmus.Type.String{ 251 | length: length 252 | } 253 | } 254 | 255 | assert Litmus.validate(data, schema) == 256 | {:error, "#{field} length must be #{length} characters"} 257 | end 258 | end 259 | 260 | describe "regex validation" do 261 | test "returns :ok when value matches the regex pattern" do 262 | data = %{"username" => "user123"} 263 | 264 | schema = %{ 265 | "username" => %Litmus.Type.String{ 266 | regex: %Litmus.Type.String.Regex{ 267 | pattern: ~r/^[a-zA-Z0-9_]*$/, 268 | error_message: "username must be alphanumeric" 269 | } 270 | } 271 | } 272 | 273 | assert Litmus.validate(data, schema) == {:ok, data} 274 | end 275 | 276 | test "errors with custom error message when value does not match regex pattern" do 277 | data = %{"username" => "x@##1"} 278 | 279 | schema = %{ 280 | "username" => %Litmus.Type.String{ 281 | regex: %Litmus.Type.String.Regex{ 282 | pattern: ~r/^[a-zA-Z0-9_]*$/, 283 | error_message: "username must be alphanumeric" 284 | } 285 | } 286 | } 287 | 288 | assert Litmus.validate(data, schema) == {:error, "username must be alphanumeric"} 289 | end 290 | 291 | test "errors with default error message when value does not match regex pattern" do 292 | data = %{"username" => "x@##1"} 293 | field = "username" 294 | 295 | schema = %{ 296 | field => %Litmus.Type.String{ 297 | regex: %Litmus.Type.String.Regex{ 298 | pattern: ~r/^\d{3,}(?:[-\s]?\d*)?$/ 299 | } 300 | } 301 | } 302 | 303 | assert Litmus.validate(data, schema) == {:error, "#{field} must be in a valid format"} 304 | end 305 | 306 | test "errors when the value is nil" do 307 | data = %{"username" => nil} 308 | field = "username" 309 | 310 | schema = %{ 311 | field => %Litmus.Type.String{ 312 | regex: %Litmus.Type.String.Regex{ 313 | pattern: ~r/^\d{3,}(?:[-\s]?\d*)?$/ 314 | } 315 | } 316 | } 317 | 318 | assert Litmus.validate(data, schema) == {:error, "#{field} must be in a valid format"} 319 | end 320 | end 321 | 322 | describe "trim extra whitespaces" do 323 | test "returns :ok with new parameters having trimmed values when trim is set to true" do 324 | data = %{"id" => " abc "} 325 | trimmed_data = %{"id" => "abc"} 326 | 327 | schema = %{ 328 | "id" => %Litmus.Type.String{ 329 | trim: true 330 | } 331 | } 332 | 333 | assert Litmus.validate(data, schema) == {:ok, trimmed_data} 334 | end 335 | 336 | test "returns :ok with same parameters when trim is set to false" do 337 | data = %{"id" => " abc "} 338 | 339 | schema = %{ 340 | "id" => %Litmus.Type.String{ 341 | trim: false 342 | } 343 | } 344 | 345 | assert Litmus.validate(data, schema) == {:ok, data} 346 | end 347 | 348 | test "does not error when the value is nil" do 349 | data = %{"id" => nil} 350 | 351 | schema = %{ 352 | "id" => %Litmus.Type.String{ 353 | trim: true 354 | } 355 | } 356 | 357 | assert Litmus.validate(data, schema) == {:ok, data} 358 | end 359 | end 360 | 361 | describe "replace" do 362 | test "replaces a pattern in a string with a new string" do 363 | data = %{"username" => "user123"} 364 | modified_data = %{"username" => "anonymous"} 365 | 366 | schema = %{ 367 | "username" => %Litmus.Type.String{ 368 | replace: %Litmus.Type.String.Replace{ 369 | pattern: ~r/^user[0-9]*$/, 370 | replacement: "anonymous" 371 | } 372 | } 373 | } 374 | 375 | assert Litmus.validate(data, schema) == {:ok, modified_data} 376 | end 377 | 378 | test "replaces multiple occurences of a regex within a string" do 379 | data = %{"username" => "user123"} 380 | modified_data = %{"username" => "userXXX"} 381 | 382 | schema = %{ 383 | "username" => %Litmus.Type.String{ 384 | replace: %Litmus.Type.String.Replace{ 385 | pattern: ~r/[0-9]/, 386 | replacement: "X" 387 | } 388 | } 389 | } 390 | 391 | assert Litmus.validate(data, schema) == {:ok, modified_data} 392 | end 393 | 394 | test "replaces multiple occurences of a string within a string" do 395 | data = %{"username" => "user212"} 396 | modified_data = %{"username" => "userX1X"} 397 | 398 | schema = %{ 399 | "username" => %Litmus.Type.String{ 400 | replace: %Litmus.Type.String.Replace{ 401 | pattern: "2", 402 | replacement: "X" 403 | } 404 | } 405 | } 406 | 407 | assert Litmus.validate(data, schema) == {:ok, modified_data} 408 | end 409 | 410 | test "replaces a single occurences of a patten when global is false" do 411 | data = %{"username" => "user123"} 412 | modified_data = %{"username" => "userX23"} 413 | 414 | schema = %{ 415 | "username" => %Litmus.Type.String{ 416 | replace: %Litmus.Type.String.Replace{ 417 | pattern: ~r/[0-9]/, 418 | replacement: "X", 419 | global: false 420 | } 421 | } 422 | } 423 | 424 | assert Litmus.validate(data, schema) == {:ok, modified_data} 425 | end 426 | end 427 | 428 | describe "convert to string" do 429 | test "returns :ok with new parameters having values converted to string when field is boolean or number" do 430 | data = %{"id" => 1, "new_user" => true} 431 | modified_data = %{"id" => "1", "new_user" => "true"} 432 | 433 | schema = %{ 434 | "id" => %Litmus.Type.String{}, 435 | "new_user" => %Litmus.Type.String{}, 436 | "description" => %Litmus.Type.String{} 437 | } 438 | 439 | assert Litmus.validate(data, schema) == {:ok, modified_data} 440 | end 441 | 442 | test "does not convert nil to a string" do 443 | data = %{"id" => nil} 444 | 445 | schema = %{ 446 | "id" => %Litmus.Type.String{} 447 | } 448 | 449 | assert Litmus.validate(data, schema) == {:ok, data} 450 | end 451 | 452 | test "returns :error when field is neither string nor boolean nor number" do 453 | data = %{"id" => ["1"]} 454 | 455 | schema = %{ 456 | "id" => %Litmus.Type.String{} 457 | } 458 | 459 | assert Litmus.validate(data, schema) == {:error, "id must be a string"} 460 | end 461 | end 462 | end 463 | -------------------------------------------------------------------------------- /test/litmus/type_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Litmus.TypeTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Litmus.Type 5 | 6 | describe "Type.validate/3" do 7 | test "validates data through Type module for Any type" do 8 | field = "id" 9 | data = %{"id" => "1"} 10 | 11 | type = %Type.Any{ 12 | required: true 13 | } 14 | 15 | assert Type.validate(type, field, data) == {:ok, data} 16 | end 17 | 18 | test "validates data through Type module for String type" do 19 | field = "id" 20 | data = %{"id" => "1"} 21 | 22 | type = %Type.String{ 23 | required: true 24 | } 25 | 26 | assert Type.validate(type, field, data) == {:ok, data} 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/litmus_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LitmusTest do 2 | use ExUnit.Case, async: true 3 | doctest Litmus 4 | 5 | alias Litmus.Type 6 | 7 | describe "validate/2" do 8 | test "validates data according to a schema" do 9 | login_schema = %{ 10 | "id" => %Litmus.Type.Any{ 11 | required: true 12 | }, 13 | "user" => %Litmus.Type.String{ 14 | max_length: 6, 15 | min_length: 3, 16 | regex: %Litmus.Type.String.Regex{ 17 | pattern: ~r/^[a-zA-Z0-9_]*$/, 18 | error_message: "username must be alphanumeric" 19 | }, 20 | trim: true 21 | }, 22 | "password" => %Litmus.Type.String{ 23 | length: 4, 24 | required: true, 25 | trim: true 26 | }, 27 | "pin" => %Litmus.Type.Number{ 28 | min: 1000, 29 | max: 9999, 30 | integer: true 31 | }, 32 | "remember_me" => %Litmus.Type.Boolean{ 33 | truthy: [1], 34 | falsy: [0] 35 | }, 36 | "account_ids" => %Litmus.Type.List{ 37 | type: :number, 38 | min_length: 2, 39 | max_length: 5 40 | }, 41 | "start_date" => %Litmus.Type.DateTime{}, 42 | "email" => %Litmus.Type.String{ 43 | default: "" 44 | } 45 | } 46 | 47 | params = %{ 48 | "id" => "abc", 49 | "password" => " 1234 ", 50 | "user" => "qwerty", 51 | "pin" => 3636, 52 | "remember_me" => 1, 53 | "account_ids" => [523, 524, 599], 54 | "start_date" => "1990-05-01T06:32:00Z" 55 | } 56 | 57 | modified_params = %{ 58 | Map.put(params, "email", "") 59 | | "password" => String.trim(params["password"]), 60 | "remember_me" => true, 61 | "start_date" => params["start_date"] |> DateTime.from_iso8601() |> elem(1) 62 | } 63 | 64 | assert Litmus.validate(params, login_schema) == {:ok, modified_params} 65 | end 66 | 67 | test "errors when a disallowed parameter is passed" do 68 | login_schema = %{ 69 | "id" => %Type.Any{ 70 | required: true 71 | } 72 | } 73 | 74 | params = %{ 75 | "id" => "1", 76 | "abc" => true 77 | } 78 | 79 | assert Litmus.validate(params, login_schema) == {:error, "abc is not allowed"} 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------