├── .credo.exs ├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── LICENSE ├── README.md ├── lib ├── parameter.ex └── parameter │ ├── dumper.ex │ ├── enum.ex │ ├── field.ex │ ├── loader.ex │ ├── meta.ex │ ├── parametrizable.ex │ ├── schema.ex │ ├── schema │ └── compiler.ex │ ├── schema_fields.ex │ ├── types.ex │ ├── types │ ├── any.ex │ ├── array.ex │ ├── atom.ex │ ├── boolean.ex │ ├── date.ex │ ├── datetime.ex │ ├── decimal.ex │ ├── float.ex │ ├── integer.ex │ ├── map.ex │ ├── naive_datetime.ex │ ├── string.ex │ └── time.ex │ ├── validator.ex │ └── validators.ex ├── logo.png ├── mix.exs ├── mix.lock └── test ├── parameter ├── enum_test.exs ├── exclude_fields_test.exs ├── field_test.exs ├── parametrizable_test.exs ├── schema │ └── compiler_test.exs ├── schema_test.exs ├── types │ ├── any_test.exs │ ├── atom_test.exs │ ├── boolean_test.exs │ ├── date_test.exs │ ├── datetime_test.exs │ ├── float_test.exs │ └── naive_datetime_test.exs ├── types_test.exs └── validators_test.exs ├── parameter_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 config using `mix credo -C `. If no config 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: [ 25 | "lib/", 26 | "src/", 27 | "test/", 28 | "web/", 29 | "apps/*/lib/", 30 | "apps/*/src/", 31 | "apps/*/test/", 32 | "apps/*/web/" 33 | ], 34 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/", "lib/parameter/enum.ex"] 35 | }, 36 | # 37 | # Load and configure plugins here: 38 | # 39 | plugins: [], 40 | # 41 | # If you create your own checks, you must specify the source files for 42 | # them here, so they can be loaded by Credo before running the analysis. 43 | # 44 | requires: [], 45 | # 46 | # If you want to enforce a style guide and need a more traditional linting 47 | # experience, you can change `strict` to `true` below: 48 | # 49 | strict: false, 50 | # 51 | # To modify the timeout for parsing files, change this value: 52 | # 53 | parse_timeout: 5000, 54 | # 55 | # If you want to use uncolored output by default, you can change `color` 56 | # to `false` below: 57 | # 58 | color: true, 59 | # 60 | # You can customize the parameters of any check by adding a second element 61 | # to the tuple. 62 | # 63 | # To disable a check put `false` as second element: 64 | # 65 | # {Credo.Check.Design.DuplicatedCode, false} 66 | # 67 | checks: %{ 68 | enabled: [ 69 | # 70 | ## Consistency Checks 71 | # 72 | {Credo.Check.Consistency.ExceptionNames, []}, 73 | {Credo.Check.Consistency.LineEndings, []}, 74 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 75 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 76 | {Credo.Check.Consistency.SpaceInParentheses, []}, 77 | {Credo.Check.Consistency.TabsOrSpaces, []}, 78 | 79 | # 80 | ## Design Checks 81 | # 82 | # You can customize the priority of any check 83 | # Priority values are: `low, normal, high, higher` 84 | # 85 | {Credo.Check.Design.AliasUsage, 86 | [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 87 | # You can also customize the exit_status of each check. 88 | # If you don't want TODO comments to cause `mix credo` to fail, just 89 | # set this value to 0 (zero). 90 | # 91 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 92 | {Credo.Check.Design.TagFIXME, []}, 93 | 94 | # 95 | ## Readability Checks 96 | # 97 | {Credo.Check.Readability.AliasOrder, []}, 98 | {Credo.Check.Readability.FunctionNames, []}, 99 | {Credo.Check.Readability.LargeNumbers, []}, 100 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 101 | {Credo.Check.Readability.ModuleAttributeNames, []}, 102 | {Credo.Check.Readability.ModuleDoc, []}, 103 | {Credo.Check.Readability.ModuleNames, []}, 104 | {Credo.Check.Readability.ParenthesesInCondition, []}, 105 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 106 | {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, 107 | {Credo.Check.Readability.PredicateFunctionNames, []}, 108 | {Credo.Check.Readability.PreferImplicitTry, []}, 109 | {Credo.Check.Readability.RedundantBlankLines, []}, 110 | {Credo.Check.Readability.Semicolons, []}, 111 | {Credo.Check.Readability.SpaceAfterCommas, []}, 112 | {Credo.Check.Readability.StringSigils, []}, 113 | {Credo.Check.Readability.TrailingBlankLine, []}, 114 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 115 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 116 | {Credo.Check.Readability.VariableNames, []}, 117 | {Credo.Check.Readability.WithSingleClause, []}, 118 | 119 | # 120 | ## Refactoring Opportunities 121 | # 122 | {Credo.Check.Refactor.Apply, []}, 123 | {Credo.Check.Refactor.CondStatements, []}, 124 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 125 | {Credo.Check.Refactor.FunctionArity, []}, 126 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 127 | {Credo.Check.Refactor.MatchInCondition, []}, 128 | {Credo.Check.Refactor.MapJoin, []}, 129 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 130 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 131 | {Credo.Check.Refactor.Nesting, [max_nesting: 3]}, 132 | {Credo.Check.Refactor.UnlessWithElse, []}, 133 | {Credo.Check.Refactor.WithClauses, []}, 134 | {Credo.Check.Refactor.FilterCount, []}, 135 | {Credo.Check.Refactor.FilterFilter, []}, 136 | {Credo.Check.Refactor.RejectReject, []}, 137 | {Credo.Check.Refactor.RedundantWithClauseResult, []}, 138 | 139 | # 140 | ## Warnings 141 | # 142 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 143 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 144 | {Credo.Check.Warning.Dbg, []}, 145 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 146 | {Credo.Check.Warning.IExPry, []}, 147 | {Credo.Check.Warning.IoInspect, []}, 148 | {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, 149 | {Credo.Check.Warning.OperationOnSameValues, []}, 150 | {Credo.Check.Warning.OperationWithConstantResult, []}, 151 | {Credo.Check.Warning.RaiseInsideRescue, []}, 152 | {Credo.Check.Warning.SpecWithStruct, []}, 153 | {Credo.Check.Warning.WrongTestFileExtension, []}, 154 | {Credo.Check.Warning.UnusedEnumOperation, []}, 155 | {Credo.Check.Warning.UnusedFileOperation, []}, 156 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 157 | {Credo.Check.Warning.UnusedListOperation, []}, 158 | {Credo.Check.Warning.UnusedPathOperation, []}, 159 | {Credo.Check.Warning.UnusedRegexOperation, []}, 160 | {Credo.Check.Warning.UnusedStringOperation, []}, 161 | {Credo.Check.Warning.UnusedTupleOperation, []}, 162 | {Credo.Check.Warning.UnsafeExec, []} 163 | ], 164 | disabled: [ 165 | # 166 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) 167 | 168 | # 169 | # Controversial and experimental checks (opt-in, just move the check to `:enabled` 170 | # and be sure to use `mix credo --strict` to see low priority checks) 171 | # 172 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, 173 | {Credo.Check.Consistency.UnusedVariableNames, []}, 174 | {Credo.Check.Design.DuplicatedCode, []}, 175 | {Credo.Check.Design.SkipTestWithoutComment, []}, 176 | {Credo.Check.Readability.AliasAs, []}, 177 | {Credo.Check.Readability.BlockPipe, []}, 178 | {Credo.Check.Readability.ImplTrue, []}, 179 | {Credo.Check.Readability.MultiAlias, []}, 180 | {Credo.Check.Readability.NestedFunctionCalls, []}, 181 | {Credo.Check.Readability.OneArityFunctionInPipe, []}, 182 | {Credo.Check.Readability.SeparateAliasRequire, []}, 183 | {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, 184 | {Credo.Check.Readability.SinglePipe, []}, 185 | {Credo.Check.Readability.Specs, []}, 186 | {Credo.Check.Readability.StrictModuleLayout, []}, 187 | {Credo.Check.Readability.WithCustomTaggedTuple, []}, 188 | {Credo.Check.Readability.OnePipePerLine, []}, 189 | {Credo.Check.Refactor.ABCSize, []}, 190 | {Credo.Check.Refactor.AppendSingleItem, []}, 191 | {Credo.Check.Refactor.DoubleBooleanNegation, []}, 192 | {Credo.Check.Refactor.FilterReject, []}, 193 | {Credo.Check.Refactor.IoPuts, []}, 194 | {Credo.Check.Refactor.MapMap, []}, 195 | {Credo.Check.Refactor.ModuleDependencies, []}, 196 | {Credo.Check.Refactor.NegatedIsNil, []}, 197 | {Credo.Check.Refactor.PassAsyncInTestCases, []}, 198 | {Credo.Check.Refactor.PipeChainStart, []}, 199 | {Credo.Check.Refactor.RejectFilter, []}, 200 | {Credo.Check.Refactor.VariableRebinding, []}, 201 | {Credo.Check.Warning.LazyLogging, []}, 202 | {Credo.Check.Warning.LeakyEnvironment, []}, 203 | {Credo.Check.Warning.MapGetUnsafePass, []}, 204 | {Credo.Check.Warning.MixEnv, []}, 205 | {Credo.Check.Warning.UnsafeToAtom, []} 206 | 207 | # {Credo.Check.Refactor.MapInto, []}, 208 | 209 | # 210 | # Custom checks can be created using `mix credo.gen.check`. 211 | # 212 | ] 213 | } 214 | } 215 | ] 216 | } 217 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | locals_without_parens = [ 2 | param: 1, 3 | param: 2, 4 | field: 2, 5 | field: 3, 6 | has_one: 2, 7 | has_one: 3, 8 | has_one: 4, 9 | has_many: 2, 10 | has_many: 3, 11 | has_many: 4, 12 | enum: 1, 13 | enum: 2, 14 | value: 2 15 | ] 16 | 17 | [ 18 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 19 | locals_without_parens: locals_without_parens, 20 | export: [ 21 | locals_without_parens: locals_without_parens 22 | ] 23 | ] 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "mix" 4 | directory: "/" 5 | open-pull-requests-limit: 30 6 | schedule: 7 | interval: "daily" 8 | time: "03:37" # UTC 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | ci: 11 | env: 12 | MIX_ENV: test 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | include: 17 | - pair: 18 | elixir: '1.15' 19 | otp: '25.3' 20 | lint: lint 21 | - pair: 22 | elixir: '1.16' 23 | otp: '26.1' 24 | lint: lint 25 | 26 | runs-on: ubuntu-latest 27 | 28 | steps: 29 | - uses: actions/checkout@v2 30 | 31 | - uses: erlef/setup-beam@v1 32 | with: 33 | otp-version: ${{matrix.pair.otp}} 34 | elixir-version: ${{matrix.pair.elixir}} 35 | 36 | - uses: actions/cache@v2 37 | with: 38 | path: | 39 | deps 40 | _build 41 | key: ${{ runner.os }}-mix-${{matrix.pair.elixir}}-${{matrix.pair.otp}}-${{ hashFiles('**/mix.lock') }} 42 | restore-keys: | 43 | ${{ runner.os }}-mix-${{matrix.pair.elixir}}-${{matrix.pair.otp}}- 44 | 45 | - name: Run mix deps.get 46 | run: mix deps.get --only test 47 | 48 | - name: Run mix format 49 | run: mix format --check-formatted 50 | if: ${{ matrix.lint }} 51 | 52 | - name: Run mix deps.compile 53 | run: mix deps.compile 54 | 55 | - name: Run mix compile 56 | run: mix compile --warnings-as-errors 57 | if: ${{ matrix.lint }} 58 | 59 | - name: Run credo 60 | run: mix credo --strict 61 | if: ${{ matrix.lint }} 62 | 63 | - name: Run mix test 64 | run: mix test 65 | 66 | - name: Run dialyzer 67 | run: mix dialyzer 68 | if: ${{ matrix.lint }} 69 | 70 | - name: Run Coveralls 71 | run: mix coveralls.github 72 | env: 73 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 74 | -------------------------------------------------------------------------------- /.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 third-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 | parameter-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.15.7-otp-26 2 | erlang 26.1.2 -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.14.0 (2024-08-21) 4 | 5 | ### Bug fixes 6 | 7 | - [Parameter] Fix `nil` default values not being shown on load/dump output. 8 | 9 | ## v0.13.x (2023-06-19) 10 | 11 | ### Breaking Changes 12 | 13 | - [Parameter] `on_load/2` or `on_dump/2` now always run when parsing the schema. Previously, both functions were only triggered when the field was present on the params. 14 | 15 | ## v0.12.x (2023-03-06) 16 | 17 | ### Enhancements 18 | 19 | - [Parameter.Schema.Compiler] Unify schema compiler for macro and functional API. 20 | - [Parameter.Types.Any] Renamed module to `Parameter.Types.AnyType` to fix elixir warnings 21 | - [Parameter] Fix `load`, `dump` and `validate` functions to correctly parse Enum values in nested schema. 22 | - [Parameter.Schema] default options for nested schema is now available. 23 | - [Parameter.Schema] Add a description on what `use Parameter.Schema` does. 24 | 25 | ### Deprecations 26 | 27 | - [Parameter.Types.List] removed type in favor of `Array` type 28 | 29 | ## v0.11.x (2023-01-18) 30 | 31 | ### Enhancements 32 | 33 | - [Parameter] Extra option `ignore_empty` for `load/2` and `dump/2` functions. 34 | - [Parameter.Schema] `has_one` and `has_many` nested types as `map` and `array` composite types. 35 | 36 | ### Deprecations 37 | 38 | - [Parameter.Enum] removed enum value macro with the `as` key. 39 | 40 | ## v0.10.x (2023-01-07) 41 | 42 | ### Enhancements 43 | 44 | - [Parameter] Extra option `ignore_nil` for `load/2` and `dump/2` functions. 45 | - [Parameter.Types] New `array` type. 46 | - [Parameter.Schema] Support for nested type on `map` and `array`. 47 | - Improved code test coverage to 100%. 48 | 49 | ## v0.9.x (2023-01-04) 50 | 51 | ### Enhancements 52 | 53 | - [Parameter.Field] Support for `on_load/2` and `on_dump/2` functions in field definition. 54 | 55 | ## v0.8.x (2022-12-10) 56 | 57 | ### Enhancements 58 | 59 | - [Parameter.Schema] Supports `compile/1` function for compiling runtime schemas. 60 | - [Parameter] `load/3`, `validate/3` and `dump/3` now support evaluating parameters using runtime schemas. 61 | - [Parameter.Validators] Improved `length/2` validator to support `min` and/or `max` attributes. Before it was only accepting both. 62 | 63 | ### Bug fixes 64 | 65 | - [Parameter] Fix a bug where `load/3` and `validate/3` was evaluating the `validator` option wrongly. 66 | - [Parameter.Field] Validator typespec. 67 | - [Parameter.Enum] Fix evaluation of enum values inside `Enum` macro 68 | 69 | ## v0.7.x (2022-11-07) 70 | 71 | ### Enhancements 72 | 73 | - [Parameter] New `validate/3` function 74 | - [Parameter] Supports for `load/3` parsing maps with atom keys 75 | - [Parameter.Schema] Supports for `fields_required` module attribute on schema 76 | 77 | ### Bug fixes 78 | 79 | - [Parameter] `dump/3` function to load the value to be dumped 80 | - [Parameter] consider basic types when loading, dumping or validating a schema 81 | - [Parameter.Field] remove compile time verification for custom types 82 | 83 | ## v0.6.x (2022-11-06) 84 | 85 | ### Enhancements 86 | 87 | - [Parameter] API changes to support new [parameter_ecto](https://github.com/phcurado/parameter_ecto) library 88 | - [Parameter] Support for `many` flag on `load/3` and `dump/3` options 89 | - [Parameter] Errors when parsing list return as map with `%{index => reason}` now instead of `{:#{index}, reason}` to avoid atom creation 90 | - [Parameter.Field] Support for `load_default` and `dump_default` options 91 | - [Parameter.Enum] Deprecated `as` in favour of `key` 92 | 93 | ### Bug fixes 94 | 95 | - [Parameter.Field] Return `default` value when calling `Parameter.dump/3` with empty value. 96 | 97 | ## v0.5.x (2022-10-26) 98 | 99 | ### Enhancements 100 | 101 | - [Parameter] Support for `exclude` option for `load/3` and `dump/3`. 102 | - [Parameter.Types] Support for `any` type 103 | - [Parameter.Schema] Support for virtual fields 104 | 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Parameter 2 | 3 | parameter 4 | 5 | [![CI](https://github.com/phcurado/parameter/workflows/ci/badge.svg?branch=main)](https://github.com/phcurado/parameter/actions?query=branch%3Amain+workflow%3Aci) 6 | [![Coverage Status](https://coveralls.io/repos/github/phcurado/parameter/badge.svg?branch=main)](https://coveralls.io/github/phcurado/parameter?branch=main) 7 | [![Hex.pm](https://img.shields.io/hexpm/v/parameter)](https://hex.pm/packages/parameter) 8 | [![HexDocs.pm](https://img.shields.io/badge/Docs-HexDocs-blue)](https://hexdocs.pm/parameter) 9 | [![License](https://img.shields.io/hexpm/l/parameter.svg)](https://hex.pm/packages/parameter) 10 | 11 | `Parameter` helps you shape data from external sources into Elixir internal types. Use it to deal with any external data in general, such as API integrations, parsing user input, or validating data that comes into your system. 12 | 13 | `Parameter` offers the following helpers: 14 | 15 | - Schema creation and validation 16 | - Input data validation 17 | - Deserialization 18 | - Serialization 19 | 20 | ## Examples 21 | 22 | ```elixir 23 | defmodule UserParam do 24 | use Parameter.Schema 25 | alias Parameter.Validators 26 | 27 | param do 28 | field :first_name, :string, key: "firstName", required: true 29 | field :last_name, :string, key: "lastName" 30 | field :email, :string, validator: &Validators.email/1 31 | has_one :address, Address do 32 | field :city, :string, required: true 33 | field :street, :string 34 | field :number, :integer 35 | end 36 | end 37 | end 38 | ``` 39 | 40 | Load (deserialize) the schema against external parameters: 41 | 42 | ```elixir 43 | params = %{ 44 | "firstName" => "John", 45 | "lastName" => "Doe", 46 | "email" => "john@email.com", 47 | "address" => %{"city" => "New York", "street" => "York"} 48 | } 49 | Parameter.load(UserParam, params) 50 | {:ok, %{ 51 | first_name: "John", 52 | last_name: "Doe", 53 | email: "john@email.com", 54 | address: %{city: "New York", street: "York"} 55 | }} 56 | ``` 57 | 58 | or Dump (serialize) a populated schema to params: 59 | 60 | ```elixir 61 | schema = %{ 62 | first_name: "John", 63 | last_name: "Doe", 64 | email: "john@email.com", 65 | address: %{city: "New York", street: "York"} 66 | } 67 | 68 | Parameter.dump(UserParam, params) 69 | {:ok, 70 | %{ 71 | "firstName" => "John", 72 | "lastName" => "Doe", 73 | "email" => "john@email.com", 74 | "address" => %{"city" => "New York", "street" => "York"} 75 | }} 76 | ``` 77 | 78 | Parameter offers a similar Schema model from [Ecto](https://github.com/elixir-ecto/ecto) library for creating a schema and parsing it against external data. The main use case of this library is to parse response from external APIs but you may also use to validate parameters in Phoenix Controllers, when receiving requests to validate it's parameters. In general `Parameter` can be used to build strucutred data and deal with serialization/deserialization of data. Check the [official documentation](https://hexdocs.pm/parameter/) for more information. 79 | 80 | ## Installation 81 | 82 | Add `parameter` to your list of dependencies in `mix.exs`: 83 | 84 | ```elixir 85 | def deps do 86 | [ 87 | {:parameter, "~> 0.14"} 88 | ] 89 | end 90 | ``` 91 | 92 | add `:parameter` on `.formatter.exs`: 93 | 94 | ```elixir 95 | import_deps: [:parameter] 96 | ``` 97 | 98 | ## Validating parameters on Controllers 99 | 100 | Parameter let's you define the shape of the data that it's expected to receive in Phoenix Controllers: 101 | 102 | ```elixir 103 | defmodule MyProjectWeb.UserController do 104 | use MyProjectWeb, :controller 105 | import Parameter.Schema 106 | 107 | alias MyProject.Accounts 108 | 109 | param UserParams do 110 | field :first_name, :string, required: true 111 | field :last_name, :string, required: true 112 | end 113 | 114 | def create(conn, params) do 115 | with {:ok, user_params} <- Parameter.load(__MODULE__.UserParams, params), 116 | {:ok, user} <- Accounts.create_user(user_params) do 117 | render(conn, "user.json", %{user: user}) 118 | end 119 | end 120 | end 121 | ``` 122 | 123 | We can also use parameter for both request and response: 124 | 125 | ```elixir 126 | defmodule MyProjectWeb.UserController do 127 | use MyProjectWeb, :controller 128 | import Parameter.Schema 129 | 130 | alias MyProject.Accounts 131 | alias MyProject.Accounts.User 132 | 133 | param UserCreateRequest do 134 | field :first_name, :string, required: true 135 | field :last_name, :string, required: true 136 | end 137 | 138 | param UserCreateResponse do 139 | # Returning the user ID created on request 140 | field :id, :integer 141 | field :last_name, :string 142 | field :last_name, :string 143 | end 144 | 145 | def create(conn, params) do 146 | with {:ok, user_request} <- Parameter.load(__MODULE__.UserCreateRequest, params), 147 | {:ok, %User{} = user} <- Accounts.create_user(user_request), 148 | {:ok, user_response} <- Parameter.dump(__MODULE__.UserCreateResponse, user) do 149 | 150 | conn 151 | |> put_status(:created) 152 | |> json(%{user: user_response}) 153 | end 154 | end 155 | end 156 | ``` 157 | 158 | This example also shows that `Parameter` can dump the user response even if it comes from a different data strucutre. The `%User{}` struct on this example comes from `Ecto.Schema` and `Parameter` is able to convert it to params defined in `UserCreateResponse`. 159 | 160 | ## Runtime schemas 161 | 162 | It's also possible to create schemas via runtime without relying on any macros. This gives great flexibility on schema creation as now `Parameter` schemas can be created and validated dynamically: 163 | 164 | ```elixir 165 | schema = %{ 166 | first_name: [key: "firstName", type: :string, required: true], 167 | address: [type: {:map, %{street: [type: :string, required: true]}}], 168 | phones: [type: {:array, %{country: [type: :string, required: true]}}] 169 | } |> Parameter.Schema.compile!() 170 | 171 | Parameter.load(schema, %{"firstName" => "John"}) 172 | {:ok, %{first_name: "John"}} 173 | ``` 174 | 175 | The same API can also be evaluated on compile time by using module attributes: 176 | 177 | ```elixir 178 | defmodule UserParams do 179 | alias Parameter.Schema 180 | 181 | @schema %{ 182 | first_name: [key: "firstName", type: :string, required: true], 183 | address: [required: true, type: {:map, %{street: [type: :string, required: true]}}], 184 | phones: [type: {:array, %{country: [type: :string, required: true]}}] 185 | } |> Schema.compile!() 186 | 187 | def load(params) do 188 | Parameter.load(@schema, params) 189 | end 190 | end 191 | ``` 192 | 193 | Or dynamically creating schemas: 194 | 195 | ```elixir 196 | defmodule EnvParser do 197 | alias Parameter.Schema 198 | 199 | def fetch!(env, opts \\ []) do 200 | atom_env = String.to_atom(env) 201 | type = Keyword.get(opts, :type, :string) 202 | default = Keyword.get(opts, :default) 203 | 204 | %{ 205 | atom_env => [key: env, type: type, default: default, required: true] 206 | } 207 | |> Schema.compile!() 208 | |> Parameter.load(%{env => System.get_env(env)}, ignore_nil: true) 209 | |> case do 210 | {:ok, %{^atom_env => parsed_env}} -> parsed_env 211 | {:error, %{^atom_env => error}} -> raise ArgumentError, message: "#{env}: #{error}" 212 | end 213 | end 214 | end 215 | ``` 216 | 217 | And now with this code we can dynamically fetch environment variables with `System.get_env/1`, define then as `required`, convert it to the correct type and use on our application's runtime: 218 | 219 | ```elixir 220 | # runtime.ex 221 | import Config 222 | 223 | # ... 224 | 225 | config :my_app, 226 | auth_enabled?: EnvParser.fetch!("AUTH_ENABLED", default: true, type: :boolean), 227 | api_url: EnvParser.fetch!("API_URL") # using the default type string 228 | 229 | # ... 230 | ``` 231 | 232 | this will come in handy since you don't have to worry anymore when fetching environment variables, what will be the shape of the data and what type I will have to use or convert in the application, `Parameter` will do this automatically for you. 233 | 234 | This small example show one of the possibilities but this can be extended depending on your use case. 235 | A common example is to use runtime schemas when you have similar `schemas` and you want to reuse their properties across different entities: 236 | 237 | ```elixir 238 | user_base = %{first_name: [key: "firstName", type: :string, required: true]} 239 | admin_params = %{role: [key: "role", type: :string, required: true]} 240 | user_admin = Map.merge(user_base, admin_params) 241 | 242 | user_base_schema = Parameter.Schema.compile!(user_base) 243 | user_admin_schema = Parameter.Schema.compile!(user_admin) 244 | 245 | # Now we can use both schemas to serialize/deserialize data with `load` and `dump` parameter functions 246 | ``` 247 | 248 | For more info on how to create schemas, check the [schema documentation](https://hexdocs.pm/parameter/Parameter.Schema.html) 249 | 250 | ## License 251 | 252 | Copyright (c) 2022, Paulo Curado. 253 | 254 | Parameter source code is licensed under the Apache 2.0 License. 255 | 256 | -------------------------------------------------------------------------------- /lib/parameter.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter do 2 | @moduledoc """ 3 | `Parameter` helps you shape data from external sources into Elixir internal types. Use it to deal with any external data in general, such as API integrations, parsing user input, or validating data that comes into your system. 4 | 5 | `Parameter` offers the following helpers: 6 | - Schema creation and validation 7 | - Input data validation 8 | - Deserialization 9 | - Serialization 10 | 11 | ## Schema 12 | 13 | First step for dealing with external data is to create a schema that shape the data: 14 | 15 | defmodule UserParam do 16 | use Parameter.Schema 17 | alias Parameter.Validators 18 | 19 | param do 20 | field :first_name, :string, key: "firstName", required: true 21 | field :last_name, :string, key: "lastName" 22 | field :email, :string, validator: &Validators.email/1 23 | has_one :address, AddressParam do 24 | field :city, :string, required: true 25 | field :street, :string 26 | field :number, :integer 27 | end 28 | end 29 | end 30 | 31 | Now it's possible to Load (deserialize) the schema against external data: 32 | 33 | params = %{ 34 | "firstName" => "John", 35 | "lastName" => "Doe", 36 | "email" => "john@email.com", 37 | "address" => %{"city" => "New York", "street" => "York"} 38 | } 39 | Parameter.load(UserParam, params) 40 | {:ok, %{ 41 | first_name: "John", 42 | last_name: "Doe", 43 | email: "john@email.com", 44 | address: %{city: "New York", street: "York"} 45 | }} 46 | 47 | or Dump (serialize) a populated schema to the source: 48 | 49 | schema = %{ 50 | first_name: "John", 51 | last_name: "Doe", 52 | email: "john@email.com", 53 | address: %{city: "New York", street: "York"} 54 | } 55 | Parameter.dump(UserParam, params) 56 | {:ok, %{ 57 | "firstName" => "John", 58 | "lastName" => "Doe", 59 | "email" => "john@email.com", 60 | "address" => %{"city" => "New York", "street" => "York"} 61 | }} 62 | 63 | For more schema options checkout `Parameter.Schema` 64 | """ 65 | 66 | alias Parameter.Dumper 67 | alias Parameter.Field 68 | alias Parameter.Loader 69 | alias Parameter.Meta 70 | alias Parameter.Types 71 | alias Parameter.Validator 72 | 73 | @unknown_opts [:error, :ignore] 74 | 75 | @doc """ 76 | Loads parameters into the given schema. 77 | 78 | ## Options 79 | 80 | * `:struct` - If set to `true` loads the schema into a structure. If `false` (default) 81 | loads with plain maps. 82 | 83 | * `:unknown` - Defines the behaviour when unknown fields are presented on the parameters. 84 | The options are `:ignore` (default) or `:error`. 85 | 86 | * `:exclude` - Accepts a list of fields to be excluded when loading the parameters. This 87 | option is useful if you have fields in your schema are only for dump. The field will not 88 | be checked for any validation if it's on the exclude list. 89 | 90 | * `:ignore_nil` - When `true` will ignore `nil` values when loading the parameters, when `false` (default) it will load the `nil` values. 91 | 92 | * `:ignore_empty` - When `true` will ignore empty `""` values when loading the parameters, when `false` (default) it will load the empty values. 93 | 94 | * `:many` - When `true` will parse the input data as list, when `false` (default) it parses as map 95 | 96 | 97 | ## Examples 98 | 99 | defmodule UserParam do 100 | use Parameter.Schema 101 | 102 | param do 103 | field :first_name, :string, key: "firstName", required: true 104 | field :last_name, :string, key: "lastName" 105 | has_one :address, Address do 106 | field :city, :string, required: true 107 | field :street, :string 108 | field :number, :integer 109 | end 110 | end 111 | end 112 | 113 | params = %{ 114 | "address" => %{"city" => "New York", "street" => "broadway"}, 115 | "firstName" => "John", 116 | "lastName" => "Doe" 117 | } 118 | Parameter.load(UserParam, params) 119 | {:ok, %{ 120 | first_name: "John", 121 | last_name: "Doe", 122 | address: %{city: "New York", street: "broadway"} 123 | }} 124 | 125 | # Using struct options 126 | Parameter.load(UserParam, params, struct: true) 127 | {:ok, %UserParam{ 128 | first_name: "John", 129 | last_name: "Doe", 130 | address: %AddressParam{city: "New York", street: "broadway"} 131 | }} 132 | 133 | # Using many: true for lists 134 | Parameter.load(UserParam, [params, params], many: true) 135 | {:ok, 136 | [ 137 | %{ 138 | address: %{city: "New York", street: "broadway"}, 139 | first_name: "John", 140 | last_name: "Doe" 141 | }, 142 | %{ 143 | address: %{city: "New York", street: "broadway"}, 144 | first_name: "John", 145 | last_name: "Doe" 146 | } 147 | ]} 148 | 149 | # Excluding fields 150 | Parameter.load(UserParam, params, exclude: [:first_name, {:address, [:city]}]) 151 | {:ok, %{ 152 | last_name: "Doe", 153 | address: %{street: "broadway"} 154 | }} 155 | 156 | # Unknown fields should return errors 157 | params = %{"user_token" => "3hgj81312312"} 158 | Parameter.load(UserParam, params, unknown: :error) 159 | {:error, %{"user_token" => "unknown field"}} 160 | 161 | # Invalid data should return validation errors: 162 | params = %{ 163 | "address" => %{"city" => "New York", "number" => "123AB"}, 164 | "lastName" => "Doe" 165 | } 166 | Parameter.load(UserParam, params) 167 | {:error, %{ 168 | first_name: "is required", 169 | address: %{number: "invalid integer type"}, 170 | }} 171 | 172 | Parameter.load(UserParam, [params, params], many: true) 173 | {:error, 174 | %{ 175 | 0 => %{address: %{number: "invalid integer type"}, first_name: "is required"}, 176 | 1 => %{address: %{number: "invalid integer type"}, first_name: "is required"} 177 | }} 178 | 179 | ### Loading map with atom keys 180 | It's also possible to load map with atom keys. Parameter schemas should not implement the `key` 181 | option for this to work. 182 | 183 | defmodule UserParam do 184 | use Parameter.Schema 185 | 186 | param do 187 | field :first_name, :string, required: true 188 | field :last_name, :string 189 | end 190 | end 191 | 192 | params = %{ 193 | first_name: "John", 194 | last_name: "Doe" 195 | } 196 | Parameter.load(UserParam, params) 197 | {:ok, %{ 198 | first_name: "John", 199 | last_name: "Doe" 200 | }} 201 | 202 | # String maps will also be correctly loaded if they have the same key 203 | params = %{ 204 | "first_name" => "John", 205 | "last_name" => "Doe" 206 | } 207 | Parameter.load(UserParam, params) 208 | {:ok, %{ 209 | first_name: "John", 210 | last_name: "Doe" 211 | }} 212 | 213 | # But the same key should not be present in both String and Atom keys: 214 | params = %{ 215 | "first_name" => "John", 216 | first_name: "John" 217 | } 218 | Parameter.load(UserParam, params) 219 | {:error, %{ 220 | first_name: "field is present as atom and string keys" 221 | }} 222 | 223 | """ 224 | @spec load(module() | list(Field.t()), map() | list(map()), Keyword.t()) :: 225 | {:ok, any()} | {:error, any()} 226 | def load(schema, input, opts \\ []) do 227 | opts = parse_opts(opts) 228 | 229 | meta = Meta.new(schema, input, operation: :load) 230 | Loader.load(meta, opts) 231 | end 232 | 233 | @doc """ 234 | Dump the loaded parameters. 235 | 236 | ## Options 237 | 238 | * `:exclude` - Accepts a list of fields to be excluded when dumping the loaded parameter. This 239 | option is useful if you have fields in your schema are only for loading. 240 | 241 | * `:ignore_nil` - When `true` will ignore `nil` values when dumping the parameters, when `false` (default) it will dump the `nil` values. 242 | 243 | * `:ignore_empty` - When `true` will ignore empty `""` values when dumping the parameters, when `false` (default) it will dump the empty values. 244 | 245 | * `:many` - When `true` will parse the input data as list, when `false` (default) it parses as map 246 | 247 | 248 | ## Examples 249 | 250 | defmodule UserParam do 251 | use Parameter.Schema 252 | 253 | param do 254 | field :first_name, :string, key: "firstName", required: true 255 | field :last_name, :string, key: "lastName" 256 | has_one :address, Address do 257 | field :city, :string, required: true 258 | field :street, :string 259 | field :number, :integer 260 | end 261 | end 262 | end 263 | 264 | loaded_params = %{ 265 | first_name: "John", 266 | last_name: "Doe", 267 | address: %{city: "New York", street: "broadway"} 268 | } 269 | 270 | Parameter.dump(UserParam, params) 271 | {:ok, %{ 272 | "address" => %{"city" => "New York", "street" => "broadway"}, 273 | "firstName" => "John", 274 | "lastName" => "Doe" 275 | }} 276 | 277 | # excluding fields 278 | Parameter.dump(UserParam, params, exclude: [:first_name, {:address, [:city]}]) 279 | {:ok, %{ 280 | "address" => %{"street" => "broadway"}, 281 | "lastName" => "Doe" 282 | }} 283 | """ 284 | @spec dump(module() | list(Field.t()), map() | list(map), Keyword.t()) :: 285 | {:ok, any()} | {:error, any()} 286 | def dump(schema, input, opts \\ []) do 287 | exclude = Keyword.get(opts, :exclude, []) 288 | many = Keyword.get(opts, :many, false) 289 | ignore_nil = Keyword.get(opts, :ignore_nil, false) 290 | ignore_empty = Keyword.get(opts, :ignore_empty, false) 291 | 292 | Types.validate!(:array, exclude) 293 | Types.validate!(:boolean, many) 294 | Types.validate!(:boolean, ignore_nil) 295 | 296 | meta = Meta.new(schema, input, operation: :dump) 297 | 298 | Dumper.dump(meta, 299 | exclude: exclude, 300 | many: many, 301 | ignore_nil: ignore_nil, 302 | ignore_empty: ignore_empty 303 | ) 304 | end 305 | 306 | @doc """ 307 | Validate parameters. This function is meant to be used when the data is loaded or 308 | created internally. `validate/3` will validate field types, required fields and 309 | `Parameter.Validators` functions. 310 | 311 | ## Options 312 | 313 | * `:exclude` - Accepts a list of fields to be excluded when validating the parameters. 314 | 315 | * `:many` - When `true` will parse the input data as list, when `false` (default) it parses as map 316 | 317 | 318 | ## Examples 319 | 320 | defmodule UserParam do 321 | use Parameter.Schema 322 | 323 | param do 324 | field :first_name, :string, key: "firstName", required: true 325 | field :last_name, :string, key: "lastName" 326 | has_one :address, Address do 327 | field :city, :string, required: true 328 | field :street, :string 329 | field :number, :integer 330 | end 331 | end 332 | end 333 | 334 | params = %{ 335 | first_name: "John", 336 | last_name: "Doe", 337 | address: %{city: "New York", street: "broadway"} 338 | } 339 | 340 | Parameter.validate(UserParam, params) 341 | :ok 342 | 343 | # Invalid data 344 | params = %{ 345 | last_name: 12, 346 | address: %{city: "New York", street: "broadway", number: "A"} 347 | } 348 | 349 | Parameter.validate(UserParam, params) 350 | {:error, 351 | %{ 352 | address: %{number: "invalid integer type"}, 353 | first_name: "is required", 354 | last_name: "invalid string type" 355 | } 356 | } 357 | """ 358 | @spec validate(module() | list(Field.t()), map() | list(map), Keyword.t()) :: 359 | :ok | {:error, any()} 360 | def validate(schema, input, opts \\ []) do 361 | exclude = Keyword.get(opts, :exclude, []) 362 | many = Keyword.get(opts, :many, false) 363 | 364 | Types.validate!(:array, exclude) 365 | Types.validate!(:boolean, many) 366 | 367 | meta = Meta.new(schema, input, operation: :validate) 368 | Validator.validate(meta, exclude: exclude, many: many) 369 | end 370 | 371 | defp parse_opts(opts) do 372 | unknown = Keyword.get(opts, :unknown, :ignore) 373 | 374 | if unknown not in @unknown_opts do 375 | raise("unknown field options should be #{inspect(@unknown_opts)}") 376 | end 377 | 378 | struct = Keyword.get(opts, :struct, false) 379 | exclude = Keyword.get(opts, :exclude, []) 380 | many = Keyword.get(opts, :many, false) 381 | ignore_nil = Keyword.get(opts, :ignore_nil, false) 382 | ignore_empty = Keyword.get(opts, :ignore_empty, false) 383 | 384 | Types.validate!(:boolean, struct) 385 | Types.validate!(:array, exclude) 386 | Types.validate!(:boolean, many) 387 | Types.validate!(:boolean, ignore_nil) 388 | Types.validate!(:boolean, ignore_empty) 389 | 390 | [ 391 | struct: struct, 392 | unknown: unknown, 393 | exclude: exclude, 394 | many: many, 395 | ignore_nil: ignore_nil, 396 | ignore_empty: ignore_empty 397 | ] 398 | end 399 | end 400 | -------------------------------------------------------------------------------- /lib/parameter/dumper.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Dumper do 2 | @moduledoc false 3 | 4 | alias Parameter.Meta 5 | alias Parameter.Schema 6 | alias Parameter.SchemaFields 7 | alias Parameter.Types 8 | 9 | @type opts :: [exclude: list(), many: boolean(), ignore_nil: boolean(), ignore_empty: boolean()] 10 | 11 | @spec dump(Meta.t(), opts) :: {:ok, any()} | {:error, any()} 12 | def dump(%Meta{schema: schema, input: input} = meta, opts) when is_map(input) do 13 | schema_keys = Schema.field_keys(schema) 14 | 15 | Enum.reduce(schema_keys, {%{}, %{}}, fn schema_key, {result, errors} -> 16 | field = Schema.field_key(schema, schema_key) 17 | 18 | case SchemaFields.process_map_value(meta, field, opts) do 19 | {:error, error} -> 20 | errors = Map.put(errors, field.name, error) 21 | {result, errors} 22 | 23 | {:ok, :ignore} -> 24 | {result, errors} 25 | 26 | {:ok, loaded_value} -> 27 | result = Map.put(result, field.key, loaded_value) 28 | {result, errors} 29 | end 30 | end) 31 | |> parse_loaded_input() 32 | end 33 | 34 | def dump(%Meta{input: input} = meta, opts) when is_list(input) do 35 | if Keyword.get(opts, :many) do 36 | SchemaFields.process_list_value(meta, input, opts) 37 | else 38 | {:error, 39 | "received a list with `many: false`, if a list is expected pass `many: true` on options"} 40 | end 41 | end 42 | 43 | def dump(meta, _opts) do 44 | Types.dump(meta.schema, meta.input) 45 | end 46 | 47 | defp parse_loaded_input({result, errors}) do 48 | if errors == %{} do 49 | {:ok, result} 50 | else 51 | {:error, errors} 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/parameter/enum.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Enum do 2 | @moduledoc """ 3 | Enum type represents a group of constants that have a value with an associated key. 4 | 5 | ## Examples 6 | 7 | defmodule MyApp.UserParam do 8 | use Parameter.Schema 9 | 10 | enum Status do 11 | value :user_online, key: "userOnline" 12 | value :user_offline, key: "userOffline" 13 | end 14 | 15 | param do 16 | field :first_name, :string, key: "firstName" 17 | field :status, MyApp.UserParam.Status 18 | end 19 | end 20 | 21 | The `Status` enum should automatically translate the `userOnline` and `userOffline` values when loading 22 | to the respective atom values. 23 | Parameter.load(MyApp.UserParam, %{"firstName" => "John", "status" => "userOnline"}) 24 | {:ok, %{first_name: "John", status: :user_online}} 25 | 26 | Parameter.dump(MyApp.UserParam, %{first_name: "John", status: :user_online}) 27 | {:ok, %{"firstName" => "John", "status" => "userOnline"}} 28 | 29 | 30 | > #### `Using enum` {: .info} 31 | > 32 | > When you use the `enum` macro, `Parameter` creates a module under the hood, injecting under the current module. 33 | > For this reason, when referencing the enum in a Parameter field, it's required to use the full module name as shown in the examples. 34 | 35 | Enum also supports a shorter version if the key and value are already the same: 36 | 37 | defmodule MyApp.UserParam do 38 | ... 39 | enum Status, values: [:user_online, :user_offline] 40 | ... 41 | end 42 | 43 | Parameter.load(MyApp.UserParam, %{"firstName" => "John", "status" => "user_online"}) 44 | {:ok, %{first_name: "John", status: :user_online}} 45 | 46 | Using numbers is also allowed in enums: 47 | 48 | enum Status do 49 | value :active, key: 1 50 | value :pending_request, key: 2 51 | end 52 | 53 | Parameter.load(MyApp.UserParam, %{"status" => 1}) 54 | {:ok, %{status: :active}} 55 | 56 | It's also possible to create enums in different modules by using the 57 | `enum/1` macro: 58 | 59 | defmodule MyApp.Status do 60 | import Parameter.Enum 61 | 62 | enum do 63 | value :user_online, key: "userOnline" 64 | value :user_offline, key: "userOffline" 65 | end 66 | end 67 | 68 | defmodule MyApp.UserParam do 69 | use Parameter.Schema 70 | alias MyApp.Status 71 | 72 | param do 73 | field :first_name, :string, key: "firstName" 74 | field :status, Status 75 | end 76 | end 77 | 78 | And the short version: 79 | 80 | enum values: [:user_online, :user_offline] 81 | 82 | 83 | ## Dump and validate 84 | 85 | Enums can also be used for validate and dump the data. The `Parameter.validate/3` function will do strict validation, checking if the value correspond to the enum values, which are internally stored as atoms. 86 | `Parameter.dump/3` will stringify the enum atom value. By design the `Parameter.dump/3` doesn't perform strict validations but for enums, it checks at least if the value exists in the enum definition before dumping. 87 | 88 | Consider the following `Parameter.Enum` implementation: 89 | 90 | defmodule Currency do 91 | use Parameter.Schema 92 | enum Currencies, values: [:EUR, :USD] 93 | 94 | param do 95 | field :currency, __MODULE__.Currencies 96 | end 97 | end 98 | 99 | It's possible to check if the value provided it's a valid enum in parameter: 100 | 101 | iex> Parameter.validate(Currency, %{currency: :EUR}) 102 | :ok 103 | iex> Parameter.validate(Currency, %{currency: :BRL}) 104 | {:error, %{currency: "invalid enum type"}} 105 | # Using the string version should also return an error since it's expected enum values to be atoms 106 | iex> Parameter.validate(Currency, %{currency: "EUR"}) 107 | {:error, %{currency: "invalid enum type"}} 108 | 109 | And for dump the data: 110 | 111 | iex> Parameter.dump(Currency, %{currency: :EUR}) 112 | {:ok, %{"currency" => "EUR"}} 113 | iex> Parameter.dump(Currency, %{currency: :BRL}) 114 | {:error, %{currency: "invalid enum type"}} 115 | # Using the string version should also return an error since it's expected enum values to be atoms 116 | iex> Parameter.dump(Currency, %{currency: "EUR"}) 117 | {:error, %{currency: "invalid enum type"}} 118 | """ 119 | 120 | @doc false 121 | defmacro enum(do: block) do 122 | module_block = create_module_block(block) 123 | 124 | quote do 125 | unquote(module_block) 126 | end 127 | end 128 | 129 | defmacro enum(values: values) do 130 | block = 131 | quote do 132 | Enum.map(unquote(values), fn val -> 133 | value(val, key: to_string(val)) 134 | end) 135 | end 136 | 137 | quote do 138 | enum(do: unquote(block)) 139 | end 140 | end 141 | 142 | @doc false 143 | defmacro enum(module_name, do: block) do 144 | module_block = create_module_block(block) |> Macro.escape() 145 | 146 | quote bind_quoted: [module_name: module_name, module_block: module_block] do 147 | module_name = Module.concat(__ENV__.module, module_name) 148 | Module.create(module_name, module_block, __ENV__) 149 | end 150 | end 151 | 152 | defmacro enum(module_name, values: values) do 153 | block = 154 | quote bind_quoted: [values: values] do 155 | Enum.map(values, fn val -> 156 | value(val, key: to_string(val)) 157 | end) 158 | end 159 | 160 | quote do 161 | enum(unquote(module_name), do: unquote(block)) 162 | end 163 | end 164 | 165 | @doc false 166 | defmacro value(value, key: key) do 167 | quote bind_quoted: [key: key, value: value] do 168 | Module.put_attribute(__MODULE__, :enum_values, {key, value}) 169 | end 170 | end 171 | 172 | defp create_module_block(block) do 173 | quote do 174 | @moduledoc """ 175 | Enum parameter type 176 | """ 177 | use Parameter.Parametrizable 178 | 179 | Module.register_attribute(__MODULE__, :enum_values, accumulate: true) 180 | 181 | unquote(block) 182 | 183 | @impl true 184 | def load(value) do 185 | @enum_values 186 | |> Enum.find(fn {key, enum_value} -> 187 | key == value 188 | end) 189 | |> case do 190 | nil -> error_tuple() 191 | {_key, enum_value} -> {:ok, enum_value} 192 | end 193 | end 194 | 195 | @impl true 196 | def dump(value) do 197 | @enum_values 198 | |> Enum.find(fn {key, enum_value} -> 199 | value == enum_value 200 | end) 201 | |> case do 202 | nil -> error_tuple() 203 | {key, _value} -> {:ok, key} 204 | end 205 | end 206 | 207 | @impl true 208 | def validate(value) do 209 | case dump(value) do 210 | {:error, reason} -> {:error, reason} 211 | {:ok, _key} -> :ok 212 | end 213 | end 214 | 215 | defp error_tuple, do: {:error, "invalid enum type"} 216 | end 217 | end 218 | end 219 | -------------------------------------------------------------------------------- /lib/parameter/field.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Field do 2 | @moduledoc """ 3 | The field inside a Parameter Schema have the following structure: 4 | field :name, :type, opts 5 | 6 | * `:name` - Atom key that defines the field name 7 | * `:type` - Type from `Parameter.Types`. For custom types check the `Parameter.Parametrizable` behaviour. 8 | * `:opts` - Keyword with field options. 9 | 10 | ## Options 11 | * `:key` - This is the key from the params that will be converted to the field schema. Examples: 12 | * If an input field use `camelCase` for mapping `first_name`, this option should be set as "firstName". 13 | * If an input field use the same case for the field definition, this key can be ignored. 14 | 15 | * `:default` - Default value of the field when no value is given. 16 | 17 | * `:load_default` - Default value of the field when no value is given when loading with `Parameter.load/3` function. 18 | This option should not be used at the same time as `default` option. 19 | 20 | * `:dump_default` - Default value of the field when no value is given when loading with `Parameter.dump/3` function. 21 | This option should not be used at the same time as `default` option. 22 | 23 | * `:required` - Defines if the field needs to be present when parsing the input. 24 | `Parameter.load/3` will return an error if the value is missing from the input data. 25 | 26 | * `:validator` - Validation function that will validate the field after loading. 27 | 28 | * `:virtual` - If `true` the field will be ignored on `Parameter.load/3` and `Parameter.dump/3` functions. 29 | 30 | * `:on_load` - Function to specify how to load the field. The function must have two arguments where the first one is the field value and the second one 31 | will be the data to be loaded. Should return `{:ok, value}` or `{:error, reason}` tuple. 32 | 33 | * `:on_dump` - Function to specify how to dump the field. The function must have two arguments where the first one is the field value and the second one 34 | will be the data to be dumped. Should return `{:ok, value}` or `{:error, reason}` tuple. 35 | 36 | > NOTE: Validation only occurs on `Parameter.load/3`. 37 | > By desgin, data passed into `Parameter.dump/3` are considered valid. 38 | 39 | ## Example 40 | As an example having an `email` field that is required and needs email validation could be implemented this way: 41 | field :email, :string, required: true, validator: &Parameter.Validators.email/1 42 | """ 43 | 44 | alias Parameter.Types 45 | 46 | defstruct [ 47 | :name, 48 | :key, 49 | :on_load, 50 | :on_dump, 51 | default: :ignore, 52 | load_default: :ignore, 53 | dump_default: :ignore, 54 | type: :string, 55 | required: false, 56 | validator: nil, 57 | virtual: false 58 | ] 59 | 60 | @type t :: %__MODULE__{ 61 | name: atom(), 62 | key: binary(), 63 | default: any(), 64 | load_default: any(), 65 | dump_default: any(), 66 | on_load: fun() | nil, 67 | on_dump: fun() | nil, 68 | type: Types.t(), 69 | required: boolean(), 70 | validator: fun() | nil, 71 | virtual: boolean() 72 | } 73 | 74 | @doc false 75 | @spec new!(Keyword.t()) :: t() | no_return() 76 | def new!(opts) do 77 | case new(opts) do 78 | {:error, error} -> raise ArgumentError, message: error 79 | %__MODULE__{} = result -> result 80 | end 81 | end 82 | 83 | @doc false 84 | @spec new(opts :: Keyword.t()) :: t() | {:error, String.t()} 85 | def new(opts) do 86 | name = Keyword.get(opts, :name) 87 | type = Keyword.get(opts, :type) 88 | 89 | if name != nil and type != nil do 90 | do_new(opts) 91 | else 92 | {:error, "a field should have at least a name and a type"} 93 | end 94 | end 95 | 96 | defp do_new(opts) do 97 | name = Keyword.fetch!(opts, :name) 98 | default = Keyword.get(opts, :default, :ignore) 99 | load_default = Keyword.get(opts, :load_default, :ignore) 100 | dump_default = Keyword.get(opts, :dump_default, :ignore) 101 | on_load = Keyword.get(opts, :on_load) 102 | on_dump = Keyword.get(opts, :on_dump) 103 | required = Keyword.get(opts, :required, false) 104 | validator = Keyword.get(opts, :validator) 105 | virtual = Keyword.get(opts, :virtual, false) 106 | 107 | # Using Types module to validate field parameters 108 | with {:ok, opts} <- name_valid?(name, opts), 109 | key = Keyword.fetch!(opts, :key), 110 | {:ok, opts} <- fetch_default(opts, default, load_default, dump_default), 111 | :ok <- Types.validate(:string, key), 112 | :ok <- Types.validate(:boolean, required), 113 | :ok <- Types.validate(:boolean, virtual), 114 | :ok <- on_load_valid?(on_load), 115 | :ok <- on_dump_valid?(on_dump), 116 | :ok <- validator_valid?(validator) do 117 | struct!(__MODULE__, opts) 118 | end 119 | end 120 | 121 | defp name_valid?(name, opts) do 122 | case Types.validate(:atom, name) do 123 | :ok -> 124 | key = Keyword.get(opts, :key, to_string(name)) 125 | 126 | {:ok, Keyword.put(opts, :key, key)} 127 | 128 | error -> 129 | error 130 | end 131 | end 132 | 133 | defp fetch_default(opts, default, :ignore, :ignore) when default != :ignore do 134 | opts = 135 | opts 136 | |> Keyword.put(:load_default, default) 137 | |> Keyword.put(:dump_default, default) 138 | 139 | {:ok, opts} 140 | end 141 | 142 | defp fetch_default(opts, :ignore, _load_default, _dump_default) do 143 | {:ok, opts} 144 | end 145 | 146 | defp fetch_default(_opts, _default, _load_default, _dump_default) do 147 | {:error, "`default` opts should not be used with `load_default` or `dump_default`"} 148 | end 149 | 150 | defp on_load_valid?(on_load) do 151 | function_valid?(on_load, 2, "on_load must be a function") 152 | end 153 | 154 | defp on_dump_valid?(on_dump) do 155 | function_valid?(on_dump, 2, "on_dump must be a function") 156 | end 157 | 158 | defp validator_valid?(validator) do 159 | function_valid?(validator, 1, "validator must be a function") 160 | end 161 | 162 | defp function_valid?(function, arity, _message) 163 | when is_function(function, arity) or is_nil(function) or is_tuple(function) do 164 | :ok 165 | end 166 | 167 | defp function_valid?(_validator, arity, message) do 168 | {:error, "#{message} with #{arity} arity"} 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /lib/parameter/loader.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Loader do 2 | @moduledoc false 3 | 4 | alias Parameter.Meta 5 | alias Parameter.Schema 6 | alias Parameter.SchemaFields 7 | alias Parameter.Types 8 | 9 | @type opts :: [ 10 | struct: boolean(), 11 | unknow_fields: :error | :ignore, 12 | exclude: list(), 13 | ignore_nil: boolean(), 14 | ignore_empty: boolean(), 15 | many: boolean() 16 | ] 17 | 18 | @spec load(Meta.t(), opts) :: {:ok, any()} | {:error, any()} 19 | def load(%Meta{input: input} = meta, opts) when is_map(input) do 20 | unknown = Keyword.get(opts, :unknown) 21 | struct = Keyword.get(opts, :struct) 22 | 23 | case unknow_fields(meta, unknown) do 24 | :ok -> 25 | iterate_schema(meta, opts) 26 | |> parse_loaded_input() 27 | |> parse_to_struct_or_map(meta, struct: struct) 28 | 29 | error -> 30 | error 31 | end 32 | end 33 | 34 | def load(%Meta{input: input} = meta, opts) when is_list(input) do 35 | if Keyword.get(opts, :many) do 36 | SchemaFields.process_list_value(meta, input, opts) 37 | else 38 | {:error, 39 | "received a list with `many: false`, if a list is expected pass `many: true` on options"} 40 | end 41 | end 42 | 43 | def load(meta, _opts) do 44 | Types.load(meta.schema, meta.input) 45 | end 46 | 47 | defp iterate_schema(meta, opts) do 48 | schema_keys = Schema.field_keys(meta.schema) 49 | 50 | Enum.reduce(schema_keys, {%{}, %{}}, fn schema_key, {result, errors} -> 51 | field = Schema.field_key(meta.schema, schema_key) 52 | 53 | case SchemaFields.process_map_value(meta, field, opts) do 54 | {:error, error} -> 55 | errors = Map.put(errors, field.name, error) 56 | {result, errors} 57 | 58 | {:ok, :ignore} -> 59 | {result, errors} 60 | 61 | {:ok, loaded_value} -> 62 | result = Map.put(result, field.name, loaded_value) 63 | {result, errors} 64 | end 65 | end) 66 | end 67 | 68 | defp unknow_fields(%Meta{schema: schema, input: input}, :error) do 69 | schema_keys = Schema.field_keys(schema) 70 | 71 | unknow_fields = 72 | Enum.reduce(input, %{}, fn {key, _value}, acc -> 73 | if key in schema_keys do 74 | acc 75 | else 76 | Map.put(acc, key, "unknown field") 77 | end 78 | end) 79 | 80 | if unknow_fields == %{} do 81 | :ok 82 | else 83 | {:error, unknow_fields} 84 | end 85 | end 86 | 87 | defp unknow_fields(_meta, _ignore), do: :ok 88 | 89 | defp parse_loaded_input({result, errors}) do 90 | if errors == %{} do 91 | {:ok, result} 92 | else 93 | {:error, errors} 94 | end 95 | end 96 | 97 | defp parse_to_struct_or_map({:error, _error} = result, _meta, _opts), do: result 98 | 99 | defp parse_to_struct_or_map(result, _meta, struct: false), do: result 100 | 101 | defp parse_to_struct_or_map({:ok, result}, %Meta{schema: schema}, struct: true) 102 | when is_atom(schema) do 103 | {:ok, struct!(schema, result)} 104 | end 105 | 106 | defp parse_to_struct_or_map(result, _meta, _opts), do: result 107 | end 108 | -------------------------------------------------------------------------------- /lib/parameter/meta.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Meta do 2 | @moduledoc false 3 | 4 | alias Parameter.Field 5 | 6 | @type t :: %__MODULE__{ 7 | schema: module() | list(Field.t()), 8 | input: map() | list(map()), 9 | parent_input: map() | list(map()), 10 | operation: :load | :dump | :validate 11 | } 12 | 13 | defstruct [ 14 | :schema, 15 | :input, 16 | :parent_input, 17 | :operation 18 | ] 19 | 20 | def new(schema, input, params \\ []) do 21 | {_val, params} = Keyword.pop(params, :schema) 22 | {_val, params} = Keyword.pop(params, :input) 23 | {parent_input, params} = Keyword.pop(params, :parent_input) 24 | 25 | initial_params = [schema: schema, input: input, parent_input: parent_input || input] 26 | params = Keyword.merge(initial_params, params) 27 | 28 | struct!(__MODULE__, params) 29 | end 30 | 31 | def set_input(%__MODULE__{} = resolver, value) do 32 | set_field(resolver, :input, value) 33 | end 34 | 35 | def set_parent_input(%__MODULE__{} = resolver, value) do 36 | set_field(resolver, :parent_input, value) 37 | end 38 | 39 | def set_schema(%__MODULE__{} = resolver, value) do 40 | set_field(resolver, :schema, value) 41 | end 42 | 43 | def set_field(%__MODULE__{} = resolver, field, value) do 44 | Map.put(resolver, field, value) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/parameter/parametrizable.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Parametrizable do 2 | @moduledoc """ 3 | Custom types for fields can be done by implementing the `Parameter.Parametrizable` behaviour. 4 | This is useful when the basic types provided by `Parameter.Types` are not enough for loading, validating and dumping data. 5 | 6 | ## Examples 7 | 8 | To create a parameterized type, create a module as shown below: 9 | defmodule MyApp.CustomType do 10 | use Parameter.Parametrizable 11 | 12 | @impl true 13 | def load(value) do 14 | {:ok, value} 15 | end 16 | 17 | @impl true 18 | def validate(_value) do 19 | :ok 20 | end 21 | 22 | @impl true 23 | def dump(value) do 24 | {:ok, value} 25 | end 26 | end 27 | 28 | Then use the new custom type on a param schema: 29 | param MyApp.CustomParam do 30 | field :custom_field, MyApp.CustomType, key: "customField" 31 | end 32 | 33 | In general is not necessary to implement dump function since using the macro `use Parameter.Parametrizable` 34 | will already use the validate function to dump the value as a default implementation. 35 | """ 36 | 37 | @callback load(any()) :: {:ok, any()} | {:error, any()} 38 | @callback dump(any()) :: {:ok, any()} | {:error, any()} 39 | @callback validate(any()) :: :ok | {:error, any()} 40 | 41 | @doc false 42 | defmacro __using__(_) do 43 | quote do 44 | @behaviour Parameter.Parametrizable 45 | 46 | @impl true 47 | def load(value), do: {:ok, value} 48 | 49 | @impl true 50 | def dump(value) do 51 | load(value) 52 | end 53 | 54 | @impl true 55 | def validate(_value), do: :ok 56 | 57 | defoverridable(Parameter.Parametrizable) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/parameter/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Schema do 2 | @moduledoc """ 3 | The first step for building a schema for your data is to create a schema definition to model the external data. 4 | This can be achieved by using the `Parameter.Schema` macro. 5 | 6 | 7 | ## Schema 8 | The example below mimics an `User` model that have one `main_address` and a list of `phones`. 9 | 10 | defmodule User do 11 | use Parameter.Schema 12 | 13 | param do 14 | field :first_name, :string, key: "firstName", required: true 15 | field :last_name, :string, key: "lastName", required: true, default: "" 16 | has_one :main_address, Address, key: "mainAddress", required: true 17 | has_many :phones, Phone 18 | end 19 | end 20 | 21 | defmodule Address do 22 | use Parameter.Schema 23 | 24 | param do 25 | field :city, :string, required: true 26 | field :street, :string 27 | field :number, :integer 28 | end 29 | end 30 | 31 | defmodule Phone do 32 | use Parameter.Schema 33 | 34 | param do 35 | field :country, :string 36 | field :number, :integer 37 | end 38 | end 39 | 40 | `Parameter` offers other ways for creating a schema such as nesting the `has_one` and `has_many` fields. This require module name as the second parameter using `do` at the end: 41 | 42 | defmodule User do 43 | use Parameter.Schema 44 | 45 | param do 46 | field :first_name, :string, key: "firstName", required: true 47 | field :last_name, :string, key: "lastName", required: true, default: "" 48 | 49 | has_one :main_address, Address, key: "mainAddress", required: true do 50 | field :city, :string, required: true 51 | field :street, :string 52 | field :number, :integer 53 | end 54 | 55 | has_many :phones, Phone do 56 | field :country, :string 57 | field :number, :integer 58 | end 59 | end 60 | end 61 | 62 | Another possibility is avoiding creating files for a schema at all. This can be done by importing `Parameter.Schema` and using the `param/2` macro. This is useful for adding params in Phoenix controllers. For example: 63 | 64 | defmodule MyProjectWeb.UserController do 65 | use MyProjectWeb, :controller 66 | import Parameter.Schema 67 | 68 | alias MyProject.Users 69 | 70 | param UserParams do 71 | field :first_name, :string, required: true 72 | field :last_name, :string, required: true 73 | end 74 | 75 | def create(conn, params) do 76 | with {:ok, user_params} <- Parameter.load(__MODULE__.UserParams, params), 77 | {:ok, user} <- Users.create_user(user_params) do 78 | render(conn, "user.json", %{user: user}) 79 | end 80 | end 81 | end 82 | 83 | It's recommended to use this approach when the schema will only be used in a single module. 84 | 85 | > #### `use Parameter.Schema` {: .info} 86 | > 87 | > When you `use Parameter.Schema`, the `Schema` module will register 88 | > module attributes and inject functions that are necessary for 89 | > `Parameter.load/3`, `Parameter.validate/3` and `Parameter.dump/3` to fetch the schema 90 | > on runtime. 91 | 92 | ## Runtime Schemas 93 | 94 | It's also possible to create schemas via runtime without relying on any macros. 95 | The API is almost the same comparing to the macro's examples: 96 | 97 | schema = %{ 98 | first_name: [key: "firstName", type: :string, required: true], 99 | address: [type: {:map, %{street: [type: :string, required: true]}}], 100 | phones: [type: {:array, %{country: [type: :string, required: true]}}] 101 | } |> Parameter.Schema.compile!() 102 | 103 | Parameter.load(schema, %{"firstName" => "John"}) 104 | {:ok, %{first_name: "John"}} 105 | 106 | 107 | The same API can also be evaluated on compile time by using module attributes: 108 | 109 | defmodule UserParams do 110 | alias Parameter.Schema 111 | 112 | @schema %{ 113 | first_name: [key: "firstName", type: :string, required: true], 114 | address: [required: true, type: {:map, %{street: [type: :string, required: true]}}], 115 | phones: [type: {:array, %{country: [type: :string, required: true]}}] 116 | } |> Schema.compile!() 117 | 118 | def load(params) do 119 | Parameter.load(@schema, params) 120 | end 121 | end 122 | 123 | This makes it easy to dynamically create schemas or just avoid using any macros. 124 | 125 | ## Required fields 126 | 127 | By default, `Parameter.Schema` considers all fields to be optional when validating the schema. 128 | This behaviour can be changed by passing the module attribute `@fields_required true` on 129 | the module where the schema is declared. 130 | 131 | ### Example 132 | defmodule MyApp.UserSchema do 133 | use Parameter.Schema 134 | 135 | @fields_required true 136 | 137 | param do 138 | field :name, :string 139 | field :age, :integer 140 | end 141 | end 142 | 143 | Parameter.load(MyApp.UserSchema, %{}) 144 | {:error, %{age: "is required", name: "is required"}} 145 | 146 | 147 | ## Custom field loading and dumping 148 | 149 | The `load` and `dump` behavior can be customized per field by implementing `on_load` or `on_dump` functions in the field definition. 150 | This can be useful if the field needs to be fetched or even validate in a different way than the defaults implemented by `Parameter`. 151 | Both functions should return `{:ok, value}` or `{:error, reason}` tuple. 152 | 153 | For example, imagine that there is a parameter called `full_name` in your schema that you want to customize on how it will be parsed: 154 | 155 | defmodule MyApp.UserSchema do 156 | use Parameter.Schema 157 | 158 | param do 159 | field :first_name, :string 160 | field :last_name, :string 161 | field :full_name, :string, on_load: &__MODULE__.load_full_name/2 162 | end 163 | 164 | def load_full_name(value, params) do 165 | # if `full_name` is not `nil` it just return the `full_name` 166 | if value do 167 | {:ok, value} 168 | else 169 | # Otherwise it will join the `first_name` and `last_name` params 170 | {:ok, params["first_name"] <> " " <> params["last_name"]} 171 | end 172 | end 173 | end 174 | 175 | Now when loading, the full_name field will be handled by the `load_full_name/2` function: 176 | 177 | Parameter.load(MyApp.UserSchema, %{first_name: "John", last_name: "Doe", full_name: nil}) 178 | {:ok, %{first_name: "John", full_name: "John Doe", last_name: "Doe"}} 179 | 180 | The same behavior is possible when dumping the schema parameters by using `on_dump/2` function: 181 | 182 | schema = %{ 183 | level: [type: :integer, on_dump: fn value, _input -> {:ok, value || 0} end] 184 | } |> Parameter.Schema.compile!() 185 | 186 | Parameter.dump(schema, %{level: nil}) 187 | {:ok, %{"level" => 0}} 188 | """ 189 | 190 | alias Parameter.Schema.Compiler 191 | alias Parameter.Types 192 | 193 | @doc false 194 | defmacro __using__(_) do 195 | quote do 196 | import Parameter.Schema 197 | import Parameter.Enum 198 | 199 | Module.put_attribute(__MODULE__, :fields_required, false) 200 | Module.put_attribute(__MODULE__, :param_raw_fields, %{}) 201 | Module.register_attribute(__MODULE__, :param_fields, accumulate: false) 202 | end 203 | end 204 | 205 | @doc false 206 | defmacro param(do: block) do 207 | schema(__CALLER__, block) 208 | end 209 | 210 | @doc false 211 | defmacro param(module_name, do: block) do 212 | quote do 213 | Parameter.Schema.__mount_nested_schema__( 214 | unquote(module_name), 215 | __ENV__, 216 | unquote(Macro.escape(block)) 217 | ) 218 | end 219 | end 220 | 221 | @doc false 222 | defmacro field(name, type, opts \\ []) do 223 | quote bind_quoted: [name: name, type: type, opts: opts] do 224 | main_attrs = [name: name, type: type] 225 | {required?, opts} = Keyword.pop(opts, :required) 226 | 227 | param_raw_fields = Module.get_attribute(__MODULE__, :param_raw_fields) 228 | 229 | required_attrs = 230 | if required? != nil do 231 | [required: required?] 232 | else 233 | [required: @fields_required] 234 | end 235 | 236 | raw_fields = Map.put(param_raw_fields, name, [type: type] ++ required_attrs ++ opts) 237 | 238 | Module.put_attribute(__MODULE__, :param_raw_fields, raw_fields) 239 | Module.put_attribute(__MODULE__, :param_struct_fields, name) 240 | end 241 | end 242 | 243 | @doc false 244 | defmacro has_one(name, module_name, opts, do: block) do 245 | block = Macro.escape(block) 246 | 247 | quote bind_quoted: [name: name, module_name: module_name, opts: opts, block: block] do 248 | opts = Compiler.validate_nested_opts!(opts) 249 | module_name = Parameter.Schema.__mount_nested_schema__(module_name, __ENV__, block) 250 | 251 | has_one name, module_name, opts 252 | end 253 | end 254 | 255 | @doc false 256 | defmacro has_one(name, module_name, do: block) do 257 | block = Macro.escape(block) 258 | 259 | quote bind_quoted: [name: name, module_name: module_name, block: block] do 260 | module_name = Parameter.Schema.__mount_nested_schema__(module_name, __ENV__, block) 261 | 262 | has_one name, module_name 263 | end 264 | end 265 | 266 | defmacro has_one(name, type, opts) do 267 | quote bind_quoted: [name: name, type: type, opts: opts] do 268 | opts = Compiler.validate_nested_opts!(opts) 269 | field name, {:map, type}, opts 270 | end 271 | end 272 | 273 | @doc false 274 | defmacro has_one(name, type) do 275 | quote bind_quoted: [name: name, type: type] do 276 | field name, {:map, type} 277 | end 278 | end 279 | 280 | @doc false 281 | defmacro has_many(name, module_name, opts, do: block) do 282 | block = Macro.escape(block) 283 | 284 | quote bind_quoted: [name: name, module_name: module_name, opts: opts, block: block] do 285 | opts = Compiler.validate_nested_opts!(opts) 286 | module_name = Parameter.Schema.__mount_nested_schema__(module_name, __ENV__, block) 287 | 288 | has_many name, module_name, opts 289 | end 290 | end 291 | 292 | @doc false 293 | defmacro has_many(name, module_name, do: block) do 294 | block = Macro.escape(block) 295 | 296 | quote bind_quoted: [name: name, module_name: module_name, block: block] do 297 | module_name = Parameter.Schema.__mount_nested_schema__(module_name, __ENV__, block) 298 | 299 | has_many name, module_name 300 | end 301 | end 302 | 303 | defmacro has_many(name, type, opts) do 304 | quote bind_quoted: [name: name, type: type, opts: opts] do 305 | if not Types.base_type?(type) do 306 | Compiler.validate_nested_opts!(opts) 307 | end 308 | 309 | field name, {:array, type}, opts 310 | end 311 | end 312 | 313 | @doc false 314 | defmacro has_many(name, type) do 315 | quote bind_quoted: [name: name, type: type] do 316 | field name, {:array, type} 317 | end 318 | end 319 | 320 | defdelegate compile!(opts), to: Compiler, as: :compile_schema! 321 | 322 | defp schema(caller, block) do 323 | precompile = 324 | quote do 325 | if line = Module.get_attribute(__MODULE__, :param_schema_defined) do 326 | raise "param already defined for #{inspect(__MODULE__)} on line #{line}" 327 | end 328 | 329 | @param_schema_defined unquote(caller.line) 330 | 331 | Module.register_attribute(__MODULE__, :param_struct_fields, accumulate: true) 332 | 333 | unquote(block) 334 | end 335 | 336 | compile = 337 | quote do 338 | raw_params = Module.get_attribute(__MODULE__, :param_raw_fields) 339 | Module.put_attribute(__MODULE__, :param_fields, Parameter.Schema.compile!(raw_params)) 340 | end 341 | 342 | postcompile = 343 | quote unquote: false do 344 | defstruct Enum.reverse(@param_struct_fields) 345 | 346 | def __param__(:fields), do: Enum.reverse(@param_fields) 347 | 348 | def __param__(:field_names) do 349 | Enum.map(__param__(:fields), & &1.name) 350 | end 351 | 352 | def __param__(:field_keys) do 353 | field_keys(__param__(:fields)) 354 | end 355 | 356 | def __param__(:field, key: key) do 357 | field_key(__param__(:fields), key) 358 | end 359 | 360 | def __param__(:field, name: name) do 361 | Enum.find(__param__(:fields), &(&1.name == name)) 362 | end 363 | end 364 | 365 | quote do 366 | unquote(precompile) 367 | unquote(compile) 368 | unquote(postcompile) 369 | end 370 | end 371 | 372 | def fields(module) when is_atom(module) do 373 | module.__param__(:fields) 374 | end 375 | 376 | def fields(fields) when is_list(fields) do 377 | fields 378 | end 379 | 380 | def field_keys(module) when is_atom(module) do 381 | module.__param__(:field_keys) 382 | end 383 | 384 | def field_keys(fields) when is_list(fields) do 385 | Enum.map(fields, & &1.key) 386 | end 387 | 388 | def field_key(module, key) when is_atom(module) do 389 | module.__param__(:field, key: key) 390 | end 391 | 392 | def field_key(fields, key) when is_list(fields) do 393 | Enum.find(fields, &(&1.key == key)) 394 | end 395 | 396 | def __mount_nested_schema__(module_name, env, block) do 397 | block = 398 | quote do 399 | use Parameter.Schema 400 | import Parameter.Schema 401 | 402 | fields_required = Parameter.Schema.__fetch_fields_required_attr__(unquote(env.module)) 403 | 404 | Module.put_attribute(__MODULE__, :fields_required, fields_required) 405 | 406 | param do 407 | unquote(block) 408 | end 409 | end 410 | 411 | module_name = Module.concat(env.module, module_name) 412 | 413 | Module.create(module_name, block, env) 414 | module_name 415 | end 416 | 417 | def __fetch_fields_required_attr__(module) do 418 | case Module.get_attribute(module, :fields_required) do 419 | nil -> false 420 | value -> value 421 | end 422 | end 423 | end 424 | -------------------------------------------------------------------------------- /lib/parameter/schema/compiler.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Schema.Compiler do 2 | @moduledoc false 3 | alias Parameter.Field 4 | alias Parameter.Types 5 | 6 | def compile_schema!(schema) when is_map(schema) do 7 | for {name, opts} <- schema do 8 | {type, opts} = Keyword.pop(opts, :type, :string) 9 | type = compile_type!(type) 10 | 11 | field = Field.new!([name: name, type: type] ++ opts) 12 | 13 | case validate_default(field) do 14 | :ok -> field 15 | {:error, reason} -> raise ArgumentError, message: inspect(reason) 16 | end 17 | end 18 | end 19 | 20 | def compile_schema!(schema) when is_atom(schema) do 21 | schema 22 | end 23 | 24 | defp compile_type!({type, schema}) when is_tuple(schema) do 25 | {type, compile_type!(schema)} 26 | end 27 | 28 | defp compile_type!({type, schema}) do 29 | if Types.composite_type?(type) do 30 | {type, compile_schema!(schema)} 31 | else 32 | raise ArgumentError, 33 | message: 34 | "not a valid inner type, please use `{map, inner_type}` or `{array, inner_type}` for nested associations" 35 | end 36 | end 37 | 38 | defp compile_type!(type) when is_atom(type) do 39 | type 40 | end 41 | 42 | defp validate_default( 43 | %Field{default: default, load_default: load_default, dump_default: dump_default} = field 44 | ) do 45 | with :ok <- validate_default(field, default), 46 | :ok <- validate_default(field, load_default), 47 | do: validate_default(field, dump_default) 48 | end 49 | 50 | defp validate_default(_field, default) when default in [nil, :ignore] do 51 | :ok 52 | end 53 | 54 | defp validate_default(%Field{name: name} = field, default_value) do 55 | Parameter.validate([field], %{name => default_value}) 56 | end 57 | 58 | def validate_nested_opts!(opts) do 59 | keys = Keyword.keys(opts) 60 | 61 | if :validator in keys do 62 | raise ArgumentError, "validator cannot be used on nested fields" 63 | end 64 | 65 | if :on_load in keys do 66 | raise ArgumentError, "on_load cannot be used on nested fields" 67 | end 68 | 69 | if :on_dump in keys do 70 | raise ArgumentError, "on_dump cannot be used on nested fields" 71 | end 72 | 73 | opts 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/parameter/schema_fields.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.SchemaFields do 2 | @moduledoc false 3 | 4 | alias Parameter.Dumper 5 | alias Parameter.Field 6 | alias Parameter.Loader 7 | alias Parameter.Meta 8 | alias Parameter.Types 9 | alias Parameter.Validator 10 | 11 | @spec process_map_value(Meta.t(), Field.t(), Keyword.t()) :: 12 | {:ok, :ignore} | {:ok, map()} | {:ok, list()} | :ok | {:error, String.t()} 13 | def process_map_value(meta, field, opts) do 14 | exclude_fields = Keyword.get(opts, :exclude) 15 | 16 | case field_to_exclude(field.name, exclude_fields) do 17 | :include -> 18 | fetch_and_verify_input(meta, field, opts) 19 | 20 | {:exclude, nested_values} -> 21 | opts = Keyword.put(opts, :exclude, nested_values) 22 | fetch_and_verify_input(meta, field, opts) 23 | 24 | :exclude -> 25 | {:ok, :ignore} 26 | end 27 | end 28 | 29 | @spec process_list_value(Meta.t(), list(any()), Keyword.t(), boolean()) :: 30 | {:ok, :ignore} | {:ok, map()} | {:ok, list()} | :ok | {:error, String.t()} 31 | def process_list_value(meta, values, opts, change_parent? \\ true) do 32 | values 33 | |> Enum.with_index() 34 | |> Enum.reduce({[], %{}}, fn {value, index}, {acc_list, errors} -> 35 | meta = meta |> Meta.set_input(value) 36 | 37 | meta = 38 | if change_parent? do 39 | Meta.set_parent_input(meta, value) 40 | else 41 | meta 42 | end 43 | 44 | case operation_handler(meta, meta.schema, value, opts) do 45 | {:error, reason} -> 46 | {acc_list, Map.put(errors, index, reason)} 47 | 48 | {:ok, result} -> 49 | {[result | acc_list], errors} 50 | 51 | :ok -> 52 | {acc_list, errors} 53 | end 54 | end) 55 | |> parse_list_values(meta.operation) 56 | end 57 | 58 | @spec field_handler(Meta.t(), atom | Field.t(), any(), Keyword.t()) :: 59 | {:ok, :ignore} | {:ok, map()} | {:ok, list()} | :ok | {:error, String.t()} 60 | def field_handler(_meta, %Field{virtual: true}, _value, _opts) do 61 | {:ok, :ignore} 62 | end 63 | 64 | def field_handler(meta, %Field{type: {:map, schema}}, value, opts) when is_map(value) do 65 | if Types.base_type?(schema) or Types.composite_type?(schema) do 66 | value 67 | |> Enum.reduce({%{}, %{}}, fn {key, value}, {acc_map, errors} -> 68 | case operation_handler(meta, schema, value, opts) do 69 | {:error, reason} -> 70 | {acc_map, Map.put(errors, key, reason)} 71 | 72 | {:ok, result} -> 73 | {Map.put(acc_map, key, result), errors} 74 | 75 | :ok -> 76 | {acc_map, errors} 77 | end 78 | end) 79 | |> parse_map_values(meta.operation) 80 | else 81 | meta 82 | |> Meta.set_schema(schema) 83 | |> Meta.set_input(value) 84 | |> operation_handler(schema, value, opts) 85 | end 86 | end 87 | 88 | def field_handler(_meta, %Field{type: {:map, _schema}}, _value, _opts) do 89 | {:error, "invalid map type"} 90 | end 91 | 92 | def field_handler(meta, %Field{type: {:array, schema}}, values, opts) when is_list(values) do 93 | meta 94 | |> Meta.set_schema(schema) 95 | |> process_list_value(values, opts, false) 96 | end 97 | 98 | def field_handler(_meta, %Field{type: {:array, _schema}}, _values, _opts) do 99 | {:error, "invalid array type"} 100 | end 101 | 102 | def field_handler( 103 | %Meta{operation: operation} = meta, 104 | %Field{validator: validator} = field, 105 | value, 106 | opts 107 | ) 108 | when not is_nil(validator) and operation in [:load, :validate] do 109 | case operation_handler(meta, field, value, opts) do 110 | {:ok, value} -> 111 | validator 112 | |> run_validator(value) 113 | |> parse_validator_result(operation) 114 | 115 | :ok -> 116 | validator 117 | |> run_validator(value) 118 | |> parse_validator_result(operation) 119 | 120 | error -> 121 | error 122 | end 123 | end 124 | 125 | def field_handler(meta, %Field{type: _type} = field, value, opts) do 126 | operation_handler(meta, field, value, opts) 127 | end 128 | 129 | def field_handler(meta, type, value, opts) do 130 | operation_handler(meta, %Field{type: type}, value, opts) 131 | end 132 | 133 | @spec field_to_exclude(atom() | binary(), list()) :: :exclude | :include | {:exclude, list()} 134 | def field_to_exclude(field_name, exclude_fields) when is_list(exclude_fields) do 135 | exclude_fields 136 | |> Enum.find(fn 137 | {key, _value} -> field_name == key 138 | key -> field_name == key 139 | end) 140 | |> case do 141 | nil -> :include 142 | {_key, nested_values} -> {:exclude, nested_values} 143 | _ -> :exclude 144 | end 145 | end 146 | 147 | def field_to_exclude(_field_name, _exclude_fields), do: :include 148 | 149 | defp parse_list_values({_result, errors}, :validate) do 150 | if errors == %{} do 151 | :ok 152 | else 153 | {:error, errors} 154 | end 155 | end 156 | 157 | defp parse_list_values({result, errors}, _operation) do 158 | if errors == %{} do 159 | {:ok, Enum.reverse(result)} 160 | else 161 | {:error, errors} 162 | end 163 | end 164 | 165 | defp parse_map_values({_result, errors}, :validate) do 166 | if errors == %{} do 167 | :ok 168 | else 169 | {:error, errors} 170 | end 171 | end 172 | 173 | defp parse_map_values({result, errors}, _operation) do 174 | if errors == %{} do 175 | {:ok, result} 176 | else 177 | {:error, errors} 178 | end 179 | end 180 | 181 | defp run_validator({func, args}, value) do 182 | case apply(func, [value | [args]]) do 183 | :ok -> {:ok, value} 184 | error -> error 185 | end 186 | end 187 | 188 | defp run_validator(func, value) do 189 | case func.(value) do 190 | :ok -> {:ok, value} 191 | error -> error 192 | end 193 | end 194 | 195 | defp parse_validator_result({:ok, value}, :load) do 196 | {:ok, value} 197 | end 198 | 199 | defp parse_validator_result({:ok, _value}, :validate) do 200 | :ok 201 | end 202 | 203 | defp parse_validator_result(error, _operation) do 204 | error 205 | end 206 | 207 | defp operation_handler(meta, %Field{type: type} = field, value, opts) do 208 | cond do 209 | Types.composite_inner_type?(type) -> 210 | field_handler(meta, field, value, opts) 211 | 212 | meta.operation == :dump -> 213 | Types.dump(type, value) 214 | 215 | meta.operation == :load -> 216 | Types.load(type, value) 217 | 218 | meta.operation == :validate -> 219 | Types.validate(type, value) 220 | end 221 | end 222 | 223 | defp operation_handler(meta, schema, value, opts) do 224 | cond do 225 | Types.base_type?(schema) or Types.composite_type?(schema) -> 226 | field_handler(meta, schema, value, opts) 227 | 228 | meta.operation == :dump -> 229 | Dumper.dump(meta, opts) 230 | 231 | meta.operation == :load -> 232 | Loader.load(meta, opts) 233 | 234 | meta.operation == :validate -> 235 | Validator.validate(meta, opts) 236 | end 237 | end 238 | 239 | defp fetch_and_verify_input(meta, field, opts) do 240 | case fetch_input(meta, field, opts) do 241 | :error -> 242 | check_required(field, :ignore, meta.operation) 243 | 244 | {:ok, nil} -> 245 | check_nil(meta, field, opts) 246 | 247 | {:ok, ""} -> 248 | check_empty(meta, field, opts) 249 | 250 | {:ok, value} -> 251 | field_handler(meta, field, value, opts) 252 | 253 | {:error, reason} -> 254 | {:error, reason} 255 | end 256 | end 257 | 258 | defp fetch_input(%Meta{input: input} = meta, field, opts) do 259 | if has_double_key?(field, input) do 260 | {:error, "field is present as atom and string keys"} 261 | else 262 | do_fetch_input(meta, field, opts) 263 | end 264 | end 265 | 266 | defp has_double_key?(field, input) do 267 | to_string(field.name) == field.key and Map.has_key?(input, field.name) and 268 | Map.has_key?(input, field.key) 269 | end 270 | 271 | defp do_fetch_input( 272 | %Meta{operation: :load, input: input, parent_input: parent_input} = meta, 273 | %Field{on_load: on_load} = field, 274 | opts 275 | ) 276 | when not is_nil(on_load) do 277 | value = get_from_key_or_name(input, field) 278 | 279 | case on_load.(value, parent_input) do 280 | {:ok, value} -> 281 | field_handler(meta, field, value, opts) 282 | 283 | error -> 284 | error 285 | end 286 | end 287 | 288 | defp do_fetch_input( 289 | %Meta{operation: :dump, input: input, parent_input: parent_input} = meta, 290 | %Field{on_dump: on_dump} = field, 291 | opts 292 | ) 293 | when not is_nil(on_dump) do 294 | value = get_from_key_or_name(input, field) 295 | 296 | case on_dump.(value, parent_input) do 297 | {:ok, value} -> 298 | field_handler(meta, field, value, opts) 299 | 300 | error -> 301 | error 302 | end 303 | end 304 | 305 | defp do_fetch_input(%Meta{input: input}, field, _opts) do 306 | fetch_from_key_or_name(input, field) 307 | end 308 | 309 | defp get_from_key_or_name(input, field) do 310 | Map.get(input, field.key) || Map.get(input, field.name) 311 | end 312 | 313 | defp fetch_from_key_or_name(input, field) do 314 | case Map.fetch(input, field.key) do 315 | :error -> Map.fetch(input, field.name) 316 | value -> value 317 | end 318 | end 319 | 320 | defp check_required(%Field{required: true, load_default: :ignore}, value, :load) 321 | when value in [:ignore, nil] do 322 | {:error, "is required"} 323 | end 324 | 325 | defp check_required(%Field{required: true, dump_default: :ignore}, value, :validate) 326 | when value in [:ignore, nil] do 327 | {:error, "is required"} 328 | end 329 | 330 | defp check_required(%Field{load_default: default}, :ignore, :load) when default != :ignore do 331 | {:ok, default} 332 | end 333 | 334 | defp check_required(%Field{dump_default: default}, :ignore, :dump) when default != :ignore do 335 | {:ok, default} 336 | end 337 | 338 | defp check_required(_field, value, :load) do 339 | {:ok, value} 340 | end 341 | 342 | defp check_required(_field, value, :dump) do 343 | {:ok, value} 344 | end 345 | 346 | defp check_required(_field, _value, :validate) do 347 | :ok 348 | end 349 | 350 | defp check_nil(meta, field, opts) do 351 | if opts[:ignore_nil] do 352 | check_required(field, :ignore, meta.operation) 353 | else 354 | check_required(field, nil, meta.operation) 355 | end 356 | end 357 | 358 | defp check_empty(meta, field, opts) do 359 | if opts[:ignore_empty] do 360 | check_required(field, :ignore, meta.operation) 361 | else 362 | check_required(field, "", meta.operation) 363 | end 364 | end 365 | end 366 | -------------------------------------------------------------------------------- /lib/parameter/types.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Types do 2 | @moduledoc """ 3 | Parameter supports different types to be used in the field inside a schema. The available types are: 4 | 5 | * `string` 6 | * `atom` 7 | * `any` 8 | * `integer` 9 | * `float` 10 | * `boolean` 11 | * `map` 12 | * `{map, nested_type}` 13 | * `array` 14 | * `{array, nested_type}` 15 | * `date` 16 | * `time` 17 | * `datetime` 18 | * `naive_datetime` 19 | * `decimal`* 20 | * `enum`** 21 | 22 | 23 | \\* For decimal type add the [decimal](https://hexdocs.pm/decimal) library into your project. 24 | 25 | \\*\\* Check the `Parameter.Enum` for more information on how to use enums. 26 | 27 | For implementing custom types check the `Parameter.Parametrizable` module. Implementing this behavour in a module makes eligible to be a field in the schema definition. 28 | """ 29 | 30 | @type t :: base_types | composite_types 31 | 32 | @type base_types :: 33 | :string 34 | | :atom 35 | | :any 36 | | :boolean 37 | | :date 38 | | :datetime 39 | | :decimal 40 | | :float 41 | | :integer 42 | | :naive_datetime 43 | | :string 44 | | :time 45 | | :array 46 | | :map 47 | 48 | @type composite_types :: {:array, t()} | {:map, t()} 49 | 50 | @base_types ~w(atom any boolean date datetime decimal float integer naive_datetime string time)a 51 | @composite_types ~w(array map)a 52 | 53 | @spec base_type?(any) :: boolean 54 | def base_type?(type), do: type in @base_types 55 | 56 | @spec composite_inner_type?(any) :: boolean 57 | def composite_inner_type?({type, _}), do: type in @composite_types 58 | def composite_inner_type?(_), do: false 59 | 60 | @spec composite_type?(any) :: boolean 61 | def composite_type?({type, _}), do: type in @composite_types 62 | def composite_type?(type), do: type in @composite_types 63 | 64 | @types_mod %{ 65 | any: Parameter.Types.AnyType, 66 | atom: Parameter.Types.Atom, 67 | boolean: Parameter.Types.Boolean, 68 | date: Parameter.Types.Date, 69 | datetime: Parameter.Types.DateTime, 70 | decimal: Parameter.Types.Decimal, 71 | float: Parameter.Types.Float, 72 | integer: Parameter.Types.Integer, 73 | array: Parameter.Types.Array, 74 | map: Parameter.Types.Map, 75 | naive_datetime: Parameter.Types.NaiveDateTime, 76 | string: Parameter.Types.String, 77 | time: Parameter.Types.Time 78 | } 79 | 80 | @spec load(atom(), any) :: {:ok, any()} | {:error, any()} 81 | def load(type, value) do 82 | type_module = Map.get(@types_mod, type, type) 83 | type_module.load(value) 84 | rescue 85 | error -> {:error, "invalid input value #{inspect(error)}"} 86 | end 87 | 88 | @spec dump(atom(), any()) :: {:ok, any()} | {:error, any()} 89 | def dump(type, value) do 90 | type_module = Map.get(@types_mod, type, type) 91 | type_module.dump(value) 92 | rescue 93 | error -> {:error, "invalid input value #{inspect(error)}"} 94 | end 95 | 96 | @spec validate!(t(), any()) :: :ok | no_return() 97 | def validate!(type, value) do 98 | case validate(type, value) do 99 | {:error, error} -> raise ArgumentError, message: error 100 | result -> result 101 | end 102 | end 103 | 104 | @spec validate(t(), any()) :: :ok | {:error, any()} 105 | def validate(type, values) 106 | 107 | def validate({:array, inner_type}, values) when is_list(values) do 108 | Enum.reduce_while(values, :ok, fn value, acc -> 109 | case validate(inner_type, value) do 110 | :ok -> {:cont, acc} 111 | error -> {:halt, error} 112 | end 113 | end) 114 | end 115 | 116 | def validate({:array, _inner_type}, _values) do 117 | {:error, "invalid array type"} 118 | end 119 | 120 | def validate({:map, inner_type}, values) when is_map(values) do 121 | Enum.reduce_while(values, :ok, fn {_key, value}, acc -> 122 | case validate(inner_type, value) do 123 | :ok -> {:cont, acc} 124 | error -> {:halt, error} 125 | end 126 | end) 127 | end 128 | 129 | def validate({:map, _inner_type}, _values) do 130 | {:error, "invalid map type"} 131 | end 132 | 133 | def validate(type, value) do 134 | type_module = Map.get(@types_mod, type, type) 135 | type_module.validate(value) 136 | rescue 137 | error -> {:error, "invalid input value #{inspect(error)}"} 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/parameter/types/any.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Types.AnyType do 2 | @moduledoc """ 3 | Any parameter type. It will accept any input value without validations. 4 | The same value loaded will be the same dumped. 5 | """ 6 | 7 | @behaviour Parameter.Parametrizable 8 | 9 | @doc """ 10 | `Any` type will just return the same type value that is passed to load function 11 | 12 | ## Examples 13 | iex> Parameter.Types.AnyType.load(:any_atom) 14 | {:ok, :any_atom} 15 | 16 | iex> Parameter.Types.AnyType.load("some string") 17 | {:ok, "some string"} 18 | 19 | iex> Parameter.Types.AnyType.load(nil) 20 | {:ok, nil} 21 | """ 22 | @impl true 23 | def load(value), do: {:ok, value} 24 | 25 | @doc """ 26 | `AnyType` type will just return the same type value that is passed to dump function 27 | 28 | ## Examples 29 | iex> Parameter.Types.AnyType.load(:any_atom) 30 | {:ok, :any_atom} 31 | 32 | iex> Parameter.Types.AnyType.load("some string") 33 | {:ok, "some string"} 34 | 35 | iex> Parameter.Types.AnyType.load(nil) 36 | {:ok, nil} 37 | """ 38 | @impl true 39 | def dump(value), do: {:ok, value} 40 | 41 | @doc """ 42 | Always return `:ok`, any value is valid 43 | """ 44 | @impl true 45 | def validate(_value), do: :ok 46 | end 47 | -------------------------------------------------------------------------------- /lib/parameter/types/array.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Types.Array do 2 | @moduledoc """ 3 | Array parameter type 4 | """ 5 | 6 | use Parameter.Parametrizable 7 | 8 | @impl true 9 | def load(array) when is_list(array) do 10 | {:ok, array} 11 | end 12 | 13 | def load(_value) do 14 | error_tuple() 15 | end 16 | 17 | @impl true 18 | def validate(array) when is_list(array) do 19 | :ok 20 | end 21 | 22 | def validate(_value) do 23 | error_tuple() 24 | end 25 | 26 | defp error_tuple, do: {:error, "invalid array type"} 27 | end 28 | -------------------------------------------------------------------------------- /lib/parameter/types/atom.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Types.Atom do 2 | @moduledoc """ 3 | Atom parameter type 4 | """ 5 | 6 | use Parameter.Parametrizable 7 | 8 | @doc """ 9 | loads atom type 10 | 11 | ## Examples 12 | iex> Parameter.Types.Atom.load(:atom) 13 | {:ok, :atom} 14 | 15 | iex> Parameter.Types.Atom.load("atom") 16 | {:ok, :atom} 17 | 18 | iex> Parameter.Types.Atom.load(nil) 19 | {:error, "invalid atom type"} 20 | 21 | iex> Parameter.Types.Atom.load(123) 22 | {:error, "invalid atom type"} 23 | """ 24 | @impl true 25 | def load(nil), do: error_tuple() 26 | 27 | def load(value) when is_atom(value) do 28 | {:ok, value} 29 | end 30 | 31 | def load(value) when is_binary(value) do 32 | {:ok, String.to_atom(value)} 33 | end 34 | 35 | def load(_value) do 36 | error_tuple() 37 | end 38 | 39 | @doc """ 40 | validate atom type 41 | 42 | ## Examples 43 | iex> Parameter.Types.Atom.validate(:atom) 44 | :ok 45 | 46 | iex> Parameter.Types.Atom.validate("atom") 47 | {:error, "invalid atom type"} 48 | 49 | iex> Parameter.Types.Atom.validate(nil) 50 | {:error, "invalid atom type"} 51 | 52 | iex> Parameter.Types.Atom.validate(123) 53 | {:error, "invalid atom type"} 54 | """ 55 | @impl true 56 | def validate(nil), do: error_tuple() 57 | 58 | def validate(value) when is_atom(value) do 59 | :ok 60 | end 61 | 62 | def validate(_value) do 63 | error_tuple() 64 | end 65 | 66 | defp error_tuple, do: {:error, "invalid atom type"} 67 | end 68 | -------------------------------------------------------------------------------- /lib/parameter/types/boolean.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Types.Boolean do 2 | @moduledoc """ 3 | Boolean parameter type 4 | """ 5 | 6 | use Parameter.Parametrizable 7 | 8 | @doc """ 9 | loads boolean type 10 | 11 | ## Examples 12 | iex> Parameter.Types.Boolean.load(true) 13 | {:ok, true} 14 | 15 | iex> Parameter.Types.Boolean.load("true") 16 | {:ok, true} 17 | 18 | iex> Parameter.Types.Boolean.load("True") 19 | {:ok, true} 20 | 21 | iex> Parameter.Types.Boolean.load("false") 22 | {:ok, false} 23 | 24 | iex> Parameter.Types.Boolean.load(1) 25 | {:ok, true} 26 | 27 | iex> Parameter.Types.Boolean.load(0) 28 | {:ok, false} 29 | 30 | iex> Parameter.Types.Boolean.load("not boolean") 31 | {:error, "invalid boolean type"} 32 | 33 | iex> Parameter.Types.Boolean.load(:not_boolean) 34 | {:error, "invalid boolean type"} 35 | """ 36 | @impl true 37 | def load(value) when is_boolean(value) do 38 | {:ok, value} 39 | end 40 | 41 | def load(value) when is_binary(value) do 42 | case String.downcase(value) do 43 | "true" -> 44 | {:ok, true} 45 | 46 | "false" -> 47 | {:ok, false} 48 | 49 | "1" -> 50 | {:ok, true} 51 | 52 | "0" -> 53 | {:ok, false} 54 | 55 | _not_boolean -> 56 | error_tuple() 57 | end 58 | end 59 | 60 | def load(1) do 61 | {:ok, true} 62 | end 63 | 64 | def load(0) do 65 | {:ok, false} 66 | end 67 | 68 | def load(_value) do 69 | error_tuple() 70 | end 71 | 72 | @impl true 73 | def dump(value) when is_boolean(value) do 74 | {:ok, value} 75 | end 76 | 77 | def dump(_value) do 78 | error_tuple() 79 | end 80 | 81 | @doc """ 82 | validate boolean type 83 | 84 | ## Examples 85 | iex> Parameter.Types.Boolean.validate(true) 86 | :ok 87 | 88 | iex> Parameter.Types.Boolean.validate(false) 89 | :ok 90 | 91 | iex> Parameter.Types.Boolean.validate("true") 92 | {:error, "invalid boolean type"} 93 | 94 | iex> Parameter.Types.Boolean.validate(nil) 95 | {:error, "invalid boolean type"} 96 | 97 | iex> Parameter.Types.Boolean.validate(123) 98 | {:error, "invalid boolean type"} 99 | """ 100 | @impl true 101 | def validate(value) when is_boolean(value) do 102 | :ok 103 | end 104 | 105 | def validate(_value) do 106 | error_tuple() 107 | end 108 | 109 | defp error_tuple, do: {:error, "invalid boolean type"} 110 | end 111 | -------------------------------------------------------------------------------- /lib/parameter/types/date.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Types.Date do 2 | @moduledoc """ 3 | Date parameter type 4 | """ 5 | 6 | use Parameter.Parametrizable 7 | 8 | @doc """ 9 | loads Date type 10 | 11 | ## Examples 12 | iex> Parameter.Types.Date.load(%Date{year: 1990, month: 5, day: 1}) 13 | {:ok, ~D[1990-05-01]} 14 | 15 | iex> Parameter.Types.Date.load({2020, 10, 5}) 16 | {:ok, ~D[2020-10-05]} 17 | 18 | iex> Parameter.Types.Date.load("2015-01-23") 19 | {:ok, ~D[2015-01-23]} 20 | 21 | iex> Parameter.Types.Date.load("2015-25-23") 22 | {:error, "invalid date type"} 23 | 24 | iex> Parameter.Types.Date.load(:not_valid_type) 25 | {:error, "invalid date type"} 26 | """ 27 | @impl true 28 | def load(%Date{} = value) do 29 | {:ok, value} 30 | end 31 | 32 | def load({_year, _month, _day} = value) do 33 | case Date.from_erl(value) do 34 | {:error, _reason} -> error_tuple() 35 | {:ok, date} -> {:ok, date} 36 | end 37 | end 38 | 39 | def load(value) when is_binary(value) do 40 | case Date.from_iso8601(value) do 41 | {:error, _reason} -> error_tuple() 42 | {:ok, date} -> {:ok, date} 43 | end 44 | end 45 | 46 | def load(_value) do 47 | error_tuple() 48 | end 49 | 50 | @doc """ 51 | validate date type 52 | 53 | ## Examples 54 | iex> Parameter.Types.Date.validate(%Date{year: 1990, month: 5, day: 1}) 55 | :ok 56 | 57 | iex> Parameter.Types.Date.validate(~D[1990-05-01]) 58 | :ok 59 | 60 | iex> Parameter.Types.Date.validate("2015-01-23") 61 | {:error, "invalid date type"} 62 | """ 63 | @impl true 64 | def validate(%Date{}) do 65 | :ok 66 | end 67 | 68 | def validate(_value) do 69 | error_tuple() 70 | end 71 | 72 | defp error_tuple, do: {:error, "invalid date type"} 73 | end 74 | -------------------------------------------------------------------------------- /lib/parameter/types/datetime.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Types.DateTime do 2 | @moduledoc """ 3 | DateTime parameter type 4 | """ 5 | 6 | use Parameter.Parametrizable 7 | 8 | @doc """ 9 | loads DateTime type 10 | 11 | ## Examples 12 | iex> Parameter.Types.DateTime.load(~U[2018-11-15 10:00:00Z]) 13 | {:ok, ~U[2018-11-15 10:00:00Z]} 14 | 15 | iex> Parameter.Types.DateTime.load("2015-01-23T23:50:07Z") 16 | {:ok, ~U[2015-01-23 23:50:07Z]} 17 | 18 | iex> Parameter.Types.DateTime.load("2015-25-23") 19 | {:error, "invalid datetime type"} 20 | """ 21 | @impl true 22 | def load(%DateTime{} = value) do 23 | {:ok, value} 24 | end 25 | 26 | def load(value) when is_binary(value) do 27 | case DateTime.from_iso8601(value) do 28 | {:error, _reason} -> error_tuple() 29 | {:ok, date, _offset} -> {:ok, date} 30 | end 31 | end 32 | 33 | def load(_value) do 34 | error_tuple() 35 | end 36 | 37 | @doc """ 38 | validate date type 39 | 40 | ## Examples 41 | iex> Parameter.Types.DateTime.validate(~U[2018-11-15 10:00:00Z]) 42 | :ok 43 | 44 | iex> Parameter.Types.DateTime.validate(~D[1990-05-01]) 45 | {:error, "invalid datetime type"} 46 | 47 | iex> Parameter.Types.DateTime.validate("2015-01-23T23:50:07Z") 48 | {:error, "invalid datetime type"} 49 | """ 50 | @impl true 51 | def validate(%DateTime{} = _datetime) do 52 | :ok 53 | end 54 | 55 | def validate(_value) do 56 | error_tuple() 57 | end 58 | 59 | defp error_tuple, do: {:error, "invalid datetime type"} 60 | end 61 | -------------------------------------------------------------------------------- /lib/parameter/types/decimal.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Decimal) do 2 | defmodule Parameter.Types.Decimal do 3 | @moduledoc """ 4 | Decimal parameter type. 5 | Include the Decimal library on your application to use this type: 6 | def deps do 7 | [ 8 | {:parameter, "~> ..."}, 9 | {:decimal, "~> 2.0"} 10 | ] 11 | end 12 | """ 13 | 14 | use Parameter.Parametrizable 15 | 16 | @impl true 17 | def load(value) do 18 | case Decimal.cast(value) do 19 | :error -> error_tuple() 20 | {:ok, decimal} -> {:ok, decimal} 21 | end 22 | end 23 | 24 | @impl true 25 | def validate(%Decimal{}) do 26 | :ok 27 | end 28 | 29 | def validate(_value) do 30 | error_tuple() 31 | end 32 | 33 | defp error_tuple, do: {:error, "invalid decimal type"} 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/parameter/types/float.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Types.Float do 2 | @moduledoc """ 3 | Float parameter type 4 | """ 5 | 6 | use Parameter.Parametrizable 7 | 8 | @doc """ 9 | loads float type 10 | 11 | ## Examples 12 | iex> Parameter.Types.Float.load(1.5) 13 | {:ok, 1.5} 14 | 15 | iex> Parameter.Types.Float.load("2.5") 16 | {:ok, 2.5} 17 | 18 | iex> Parameter.Types.Float.load(1) 19 | {:ok, 1.0} 20 | 21 | iex> Parameter.Types.Float.load("not float") 22 | {:error, "invalid float type"} 23 | 24 | iex> Parameter.Types.Float.load(:atom) 25 | {:error, "invalid float type"} 26 | """ 27 | @impl true 28 | def load(value) when is_float(value) do 29 | {:ok, value} 30 | end 31 | 32 | def load(value) when is_binary(value) do 33 | case Float.parse(value) do 34 | {float, ""} -> {:ok, float} 35 | _error -> error_tuple() 36 | end 37 | end 38 | 39 | def load(value) when is_integer(value) do 40 | {:ok, value / 1} 41 | end 42 | 43 | def load(_value) do 44 | error_tuple() 45 | end 46 | 47 | @impl true 48 | def validate(value) when is_float(value) do 49 | :ok 50 | end 51 | 52 | def validate(_value) do 53 | error_tuple() 54 | end 55 | 56 | defp error_tuple, do: {:error, "invalid float type"} 57 | end 58 | -------------------------------------------------------------------------------- /lib/parameter/types/integer.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Types.Integer do 2 | @moduledoc """ 3 | Integer parameter type 4 | """ 5 | 6 | use Parameter.Parametrizable 7 | 8 | @impl true 9 | def load(value) when is_integer(value) do 10 | {:ok, value} 11 | end 12 | 13 | def load(value) when is_binary(value) do 14 | case Integer.parse(value) do 15 | {integer, ""} -> {:ok, integer} 16 | _error -> error_tuple() 17 | end 18 | end 19 | 20 | def load(_value) do 21 | error_tuple() 22 | end 23 | 24 | @impl true 25 | def validate(value) when is_integer(value) do 26 | :ok 27 | end 28 | 29 | def validate(_value) do 30 | error_tuple() 31 | end 32 | 33 | defp error_tuple, do: {:error, "invalid integer type"} 34 | end 35 | -------------------------------------------------------------------------------- /lib/parameter/types/map.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Types.Map do 2 | @moduledoc """ 3 | Map parameter type 4 | """ 5 | 6 | use Parameter.Parametrizable 7 | 8 | @impl true 9 | def load(map) when is_map(map) do 10 | {:ok, map} 11 | end 12 | 13 | def load(_value) do 14 | error_tuple() 15 | end 16 | 17 | @impl true 18 | def validate(map) when is_map(map) do 19 | :ok 20 | end 21 | 22 | def validate(_value) do 23 | error_tuple() 24 | end 25 | 26 | defp error_tuple, do: {:error, "invalid map type"} 27 | end 28 | -------------------------------------------------------------------------------- /lib/parameter/types/naive_datetime.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Types.NaiveDateTime do 2 | @moduledoc """ 3 | NaiveDateTime parameter type 4 | """ 5 | 6 | use Parameter.Parametrizable 7 | 8 | @doc """ 9 | loads NaiveDateTime type 10 | 11 | ## Examples 12 | iex> Parameter.Types.NaiveDateTime.load(~N[2000-01-01 23:00:07]) 13 | {:ok, ~N[2000-01-01 23:00:07]} 14 | 15 | iex> Parameter.Types.NaiveDateTime.load("2000-01-01 22:00:07") 16 | {:ok, ~N[2000-01-01 22:00:07]} 17 | 18 | iex> Parameter.Types.NaiveDateTime.load({{2021, 05, 11}, {22, 30, 10}}) 19 | {:ok, ~N[2021-05-11 22:30:10]} 20 | 21 | iex> Parameter.Types.NaiveDateTime.load({{2021, 25, 11}, {22, 30, 10}}) 22 | {:error, "invalid naive_datetime type"} 23 | 24 | iex> Parameter.Types.NaiveDateTime.load("2015-25-23") 25 | {:error, "invalid naive_datetime type"} 26 | """ 27 | @impl true 28 | def load(%NaiveDateTime{} = value) do 29 | {:ok, value} 30 | end 31 | 32 | def load({{_year, _month, _day}, {_hour, _min, _sec}} = value) do 33 | case NaiveDateTime.from_erl(value) do 34 | {:error, _reason} -> error_tuple() 35 | {:ok, date} -> {:ok, date} 36 | end 37 | end 38 | 39 | def load(value) when is_binary(value) do 40 | case NaiveDateTime.from_iso8601(value) do 41 | {:error, _reason} -> error_tuple() 42 | {:ok, date} -> {:ok, date} 43 | end 44 | end 45 | 46 | def load(_value) do 47 | error_tuple() 48 | end 49 | 50 | @impl true 51 | def validate(%NaiveDateTime{}) do 52 | :ok 53 | end 54 | 55 | def validate(_value) do 56 | error_tuple() 57 | end 58 | 59 | defp error_tuple, do: {:error, "invalid naive_datetime type"} 60 | end 61 | -------------------------------------------------------------------------------- /lib/parameter/types/string.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Types.String do 2 | @moduledoc """ 3 | String parameter type 4 | """ 5 | 6 | use Parameter.Parametrizable 7 | 8 | @impl true 9 | def load(value) do 10 | {:ok, to_string(value)} 11 | end 12 | 13 | @impl true 14 | def validate(value) when is_binary(value) do 15 | :ok 16 | end 17 | 18 | def validate(_value) do 19 | {:error, "invalid string type"} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/parameter/types/time.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Types.Time do 2 | @moduledoc """ 3 | Time parameter type 4 | """ 5 | 6 | use Parameter.Parametrizable 7 | 8 | @impl true 9 | def load(%Time{} = value) do 10 | {:ok, value} 11 | end 12 | 13 | def load({_hour, _min, _sec} = value) do 14 | case Time.from_erl(value) do 15 | {:error, _reason} -> error_tuple() 16 | {:ok, date} -> {:ok, date} 17 | end 18 | end 19 | 20 | def load(value) when is_binary(value) do 21 | case Time.from_iso8601(value) do 22 | {:error, _reason} -> error_tuple() 23 | {:ok, date} -> {:ok, date} 24 | end 25 | end 26 | 27 | def load(_value) do 28 | error_tuple() 29 | end 30 | 31 | @impl true 32 | def validate(%Time{}) do 33 | :ok 34 | end 35 | 36 | def validate(_value) do 37 | error_tuple() 38 | end 39 | 40 | defp error_tuple, do: {:error, "invalid time type"} 41 | end 42 | -------------------------------------------------------------------------------- /lib/parameter/validator.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Validator do 2 | @moduledoc false 3 | 4 | alias Parameter.Meta 5 | alias Parameter.Schema 6 | alias Parameter.SchemaFields 7 | alias Parameter.Types 8 | 9 | @type opts :: [exclude: list(), many: boolean()] 10 | 11 | @spec validate(Meta.t(), opts) :: :ok | {:error, any()} 12 | def validate(%Meta{input: input, schema: schema} = meta, opts) when is_map(input) do 13 | schema_keys = Schema.field_keys(schema) 14 | 15 | Enum.reduce(schema_keys, %{}, fn schema_key, errors -> 16 | field = Schema.field_key(schema, schema_key) 17 | 18 | case SchemaFields.process_map_value(meta, field, opts) do 19 | {:error, error} -> 20 | Map.put(errors, field.name, error) 21 | 22 | {:ok, :ignore} -> 23 | errors 24 | 25 | :ok -> 26 | errors 27 | end 28 | end) 29 | |> parse_result() 30 | end 31 | 32 | def validate(%Meta{input: input} = meta, opts) when is_list(input) do 33 | if Keyword.get(opts, :many) do 34 | SchemaFields.process_list_value(meta, input, opts) 35 | else 36 | {:error, 37 | "received a list with `many: false`, if a list is expected pass `many: true` on options"} 38 | end 39 | end 40 | 41 | def validate(meta, _opts) do 42 | Types.validate(meta.schema, meta.input) 43 | end 44 | 45 | defp parse_result(errors) do 46 | if errors == %{} do 47 | :ok 48 | else 49 | {:error, errors} 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/parameter/validators.ex: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Validators do 2 | @moduledoc """ 3 | Common validators to use within fields of the `Parameter.Schema` 4 | 5 | ## Examples 6 | 7 | param User do 8 | field :email, :string, validator: &Validators.email(&1) 9 | end 10 | 11 | iex> Parameter.load(User, %{"email" => "not an email"}) 12 | {:error, %{email: "is invalid"}} 13 | """ 14 | 15 | @type resp :: :ok | {:error, String.t()} 16 | @email_regex ~r/^[A-Za-z0-9\._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,6}$/ 17 | 18 | @doc """ 19 | Validates email address 20 | param User do 21 | field :email, :string, validator: &Validators.email(&1) 22 | end 23 | 24 | iex> Parameter.load(User, %{"email" => "not an email"}) 25 | {:error, %{email: "is invalid"}} 26 | 27 | iex> Parameter.load(User, %{"email" => "john@gmail.com"}) 28 | {:ok, %{email: "john@gmail.com"}} 29 | """ 30 | @spec email(binary()) :: resp 31 | def email(value) when is_binary(value) do 32 | if String.match?(value, @email_regex) do 33 | :ok 34 | else 35 | error_tuple() 36 | end 37 | end 38 | 39 | @doc """ 40 | Validates if value is equal to another 41 | param User do 42 | field :permission, :string, validator: {&Validators.equal/2, to: "admin"} 43 | end 44 | 45 | iex> Parameter.load(User, %{"permission" => "super_admin"}) 46 | {:error, %{permission: "is invalid"}} 47 | 48 | iex> Parameter.load(User, %{"permission" => "admin"}) 49 | {:ok, %{permission: "admin"}} 50 | """ 51 | @spec equal(binary(), to: any()) :: resp 52 | def equal(value, to: comparable) do 53 | if value == comparable do 54 | :ok 55 | else 56 | error_tuple() 57 | end 58 | end 59 | 60 | @doc """ 61 | Validates if a value is between a min and max 62 | param User do 63 | field :age, :integer, validator: {&Validators.length/2, min: 18, max: 50} 64 | end 65 | 66 | iex> Parameter.load(User, %{"age" => 12}) 67 | {:error, %{age: "is invalid"}} 68 | 69 | iex> Parameter.load(User, %{"age" => 30}) 70 | {:ok, %{age: 30}} 71 | """ 72 | @spec length(binary(), Keyword.t()) :: resp 73 | def length(value, min: min, max: max) do 74 | case length(value, min: min) do 75 | {:error, _reason} = error -> error 76 | :ok -> length(value, max: max) 77 | end 78 | end 79 | 80 | def length(value, min: min) do 81 | if value >= min do 82 | :ok 83 | else 84 | error_tuple() 85 | end 86 | end 87 | 88 | def length(value, max: max) do 89 | if value <= max do 90 | :ok 91 | else 92 | error_tuple() 93 | end 94 | end 95 | 96 | @doc """ 97 | Validates if a value is a member of the list 98 | param User do 99 | field :permission, :atom, validator: {&Validators.one_of/2, options: [:admin, :super_admin]} 100 | end 101 | 102 | iex> Parameter.load(User, %{"permission" => "normal_user"}) 103 | {:error, %{permission: "is invalid"}} 104 | 105 | iex> Parameter.load(User, %{"permission" => "super_admin"}) 106 | {:ok, %{permission: :super_admin}} 107 | """ 108 | @spec one_of(binary(), options: any()) :: resp 109 | def one_of(value, options: options) when is_list(options) do 110 | if value in options do 111 | :ok 112 | else 113 | error_tuple() 114 | end 115 | end 116 | 117 | @doc """ 118 | Validates if a value is not a member 119 | param User do 120 | field :permission, :atom, validator: {&Validators.none_of/2, options: [:admin, :super_admin]} 121 | end 122 | 123 | iex> Parameter.load(User, %{"permission" => "super_admin"}) 124 | {:error, %{permission: "is invalid"}} 125 | 126 | iex> Parameter.load(User, %{"permission" => "normal_user"}) 127 | {:ok, %{permission: :normal_user}} 128 | """ 129 | @spec none_of(binary(), options: any()) :: resp 130 | def none_of(value, options: options) do 131 | case one_of(value, options: options) do 132 | :ok -> error_tuple() 133 | _error -> :ok 134 | end 135 | end 136 | 137 | @doc """ 138 | Validates if a value matches a regex expression 139 | param User do 140 | field :code, :string, validator: {&Validators.regex/2, regex: ~r/code/} 141 | end 142 | 143 | iex> Parameter.load(User, %{"code" => "12345"}) 144 | {:error, %{code: "is invalid"}} 145 | 146 | iex> Parameter.load(User, %{"code" => "code:12345"}) 147 | {:ok, %{code: "code:12345"}} 148 | """ 149 | @spec regex(binary(), regex: any()) :: resp 150 | def regex(value, regex: regex) when is_binary(value) do 151 | if String.match?(value, regex) do 152 | :ok 153 | else 154 | error_tuple() 155 | end 156 | end 157 | 158 | defp error_tuple do 159 | {:error, "is invalid"} 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phcurado/parameter/b45f7e49503c5091910a7bb89e97b4016bf6f944/logo.png -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Parameter.MixProject do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/phcurado/parameter" 5 | @version "0.14.0" 6 | 7 | def project do 8 | [ 9 | app: :parameter, 10 | version: @version, 11 | elixir: "~> 1.13", 12 | start_permanent: Mix.env() == :prod, 13 | deps: deps(), 14 | 15 | # Hex 16 | description: 17 | "Schema creation, validation with serialization and deserialization for input data", 18 | source_url: @source_url, 19 | package: package(), 20 | # Docs 21 | name: "Parameter", 22 | docs: docs(), 23 | test_coverage: [tool: ExCoveralls], 24 | preferred_cli_env: [ 25 | coveralls: :test, 26 | "coveralls.detail": :test, 27 | "coveralls.post": :test, 28 | "coveralls.html": :test 29 | ] 30 | ] 31 | end 32 | 33 | def application do 34 | [ 35 | extra_applications: [:logger] 36 | ] 37 | end 38 | 39 | defp deps do 40 | [ 41 | {:decimal, "~> 2.0", optional: true}, 42 | {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, 43 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 44 | {:ex_doc, "~> 0.27", only: :dev, runtime: false}, 45 | {:excoveralls, "~> 0.10", only: :test} 46 | ] 47 | end 48 | 49 | defp package() do 50 | [ 51 | maintainers: ["Paulo Curado", "Ayrat Badykov"], 52 | licenses: ["Apache-2.0"], 53 | files: ~w(lib .formatter.exs mix.exs README* LICENSE*), 54 | links: %{"GitHub" => @source_url} 55 | ] 56 | end 57 | 58 | defp docs do 59 | [ 60 | main: "Parameter", 61 | logo: "logo.png", 62 | source_ref: "v#{@version}", 63 | skip_undefined_reference_warnings_on: ["CHANGELOG.md"], 64 | canonical: "https://hexdocs.pm/parameter", 65 | source_url: @source_url, 66 | extras: ["CHANGELOG.md"], 67 | groups_for_modules: [ 68 | Types: [ 69 | Parameter.Enum, 70 | Parameter.Parametrizable, 71 | Parameter.Types.AnyType, 72 | Parameter.Types.Atom, 73 | Parameter.Types.Boolean, 74 | Parameter.Types.Date, 75 | Parameter.Types.DateTime, 76 | Parameter.Types.Decimal, 77 | Parameter.Types.Float, 78 | Parameter.Types.Integer, 79 | Parameter.Types.Array, 80 | Parameter.Types.Map, 81 | Parameter.Types.NaiveDateTime, 82 | Parameter.Types.String, 83 | Parameter.Types.Time 84 | ] 85 | ] 86 | ] 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, 3 | "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, 4 | "credo": {:hex, :credo, "1.7.1", "6e26bbcc9e22eefbff7e43188e69924e78818e2fe6282487d0703652bc20fd62", [:mix], [{:bunt, "~> 0.2.1", [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", "e9871c6095a4c0381c89b6aa98bc6260a8ba6addccf7f6a53da8849c748a58a2"}, 5 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 6 | "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, 7 | "earmark_parser": {:hex, :earmark_parser, "1.4.42", "f23d856f41919f17cd06a493923a722d87a2d684f143a1e663c04a2b93100682", [:mix], [], "hexpm", "6915b6ca369b5f7346636a2f41c6a6d78b5af419d61a611079189233358b8b8b"}, 8 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 9 | "ex_doc": {:hex, :ex_doc, "0.36.1", "4197d034f93e0b89ec79fac56e226107824adcce8d2dd0a26f5ed3a95efc36b1", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "d7d26a7cf965dacadcd48f9fa7b5953d7d0cfa3b44fa7a65514427da44eafd89"}, 10 | "excoveralls": {:hex, :excoveralls, "0.18.3", "bca47a24d69a3179951f51f1db6d3ed63bca9017f476fe520eb78602d45f7756", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "746f404fcd09d5029f1b211739afb8fb8575d775b21f6a3908e7ce3e640724c6"}, 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.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 15 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 16 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 17 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 18 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 19 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 20 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 21 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 22 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 23 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 24 | } 25 | -------------------------------------------------------------------------------- /test/parameter/enum_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parameter.EnumTest do 2 | use ExUnit.Case 3 | 4 | defmodule DynamicVal do 5 | def dynamic_values, do: [:super_admin, :admin] 6 | end 7 | 8 | defmodule EnumTest do 9 | import Parameter.Enum 10 | 11 | enum values: [:user_online, :user_offline] 12 | 13 | enum JobType do 14 | value :freelancer, key: "freelancer" 15 | value :business_owner, key: "businessOwner" 16 | value :unemployed, key: "unemployed" 17 | value :employed, key: "employed" 18 | end 19 | 20 | enum JobTypeInteger do 21 | value :freelancer, key: 1 22 | value :business_owner, key: 2 23 | value :unemployed, key: 3 24 | value :employed, key: 4 25 | end 26 | 27 | enum Dynamic, values: DynamicVal.dynamic_values() 28 | end 29 | 30 | describe "load/1" do 31 | test "load EnumTest" do 32 | assert EnumTest.load("user_online") == {:ok, :user_online} 33 | assert EnumTest.load("user_offline") == {:ok, :user_offline} 34 | assert EnumTest.load(:user_offline) == {:error, "invalid enum type"} 35 | assert EnumTest.load("userOnline") == {:error, "invalid enum type"} 36 | end 37 | 38 | test "load JobType" do 39 | assert EnumTest.JobType.load("freelancer") == {:ok, :freelancer} 40 | assert EnumTest.JobType.load("businessOwner") == {:ok, :business_owner} 41 | assert EnumTest.JobType.load("unemployed") == {:ok, :unemployed} 42 | assert EnumTest.JobType.load("employed") == {:ok, :employed} 43 | assert EnumTest.JobType.load("other") == {:error, "invalid enum type"} 44 | end 45 | 46 | test "load JobTypeInteger" do 47 | assert EnumTest.JobTypeInteger.load(1) == {:ok, :freelancer} 48 | assert EnumTest.JobTypeInteger.load(2) == {:ok, :business_owner} 49 | assert EnumTest.JobTypeInteger.load(3) == {:ok, :unemployed} 50 | assert EnumTest.JobTypeInteger.load(4) == {:ok, :employed} 51 | assert EnumTest.JobTypeInteger.load(5) == {:error, "invalid enum type"} 52 | assert EnumTest.JobTypeInteger.load("5") == {:error, "invalid enum type"} 53 | assert EnumTest.JobTypeInteger.load("employed") == {:error, "invalid enum type"} 54 | end 55 | 56 | test "load DynamicEnumTest" do 57 | assert EnumTest.Dynamic.load("super_admin") == {:ok, :super_admin} 58 | assert EnumTest.Dynamic.load("admin") == {:ok, :admin} 59 | assert EnumTest.Dynamic.load(:super_admin) == {:error, "invalid enum type"} 60 | assert EnumTest.Dynamic.load("superAdmin") == {:error, "invalid enum type"} 61 | end 62 | end 63 | 64 | describe "dump/1" do 65 | test "dump EnumTest" do 66 | assert EnumTest.dump(:user_online) == {:ok, "user_online"} 67 | assert EnumTest.dump(:user_offline) == {:ok, "user_offline"} 68 | assert EnumTest.dump("user_offline") == {:error, "invalid enum type"} 69 | assert EnumTest.dump(:userOnline) == {:error, "invalid enum type"} 70 | end 71 | 72 | test "dump JobType" do 73 | assert EnumTest.JobType.dump(:freelancer) == {:ok, "freelancer"} 74 | assert EnumTest.JobType.dump(:business_owner) == {:ok, "businessOwner"} 75 | assert EnumTest.JobType.dump(:unemployed) == {:ok, "unemployed"} 76 | assert EnumTest.JobType.dump(:employed) == {:ok, "employed"} 77 | assert EnumTest.JobType.dump(:other) == {:error, "invalid enum type"} 78 | end 79 | 80 | test "dump JobTypeInteger" do 81 | assert EnumTest.JobTypeInteger.dump(:freelancer) == {:ok, 1} 82 | assert EnumTest.JobTypeInteger.dump(:business_owner) == {:ok, 2} 83 | assert EnumTest.JobTypeInteger.dump(:unemployed) == {:ok, 3} 84 | assert EnumTest.JobTypeInteger.dump(:employed) == {:ok, 4} 85 | assert EnumTest.JobTypeInteger.dump(:other) == {:error, "invalid enum type"} 86 | assert EnumTest.JobTypeInteger.dump("5") == {:error, "invalid enum type"} 87 | assert EnumTest.JobTypeInteger.dump(1) == {:error, "invalid enum type"} 88 | assert EnumTest.JobTypeInteger.dump("employed") == {:error, "invalid enum type"} 89 | end 90 | 91 | test "dump DynamicEnumTest" do 92 | assert EnumTest.Dynamic.dump(:super_admin) == {:ok, "super_admin"} 93 | assert EnumTest.Dynamic.dump(:admin) == {:ok, "admin"} 94 | assert EnumTest.Dynamic.dump("super_admin") == {:error, "invalid enum type"} 95 | assert EnumTest.Dynamic.dump(:superAdmin) == {:error, "invalid enum type"} 96 | end 97 | end 98 | 99 | describe "enum on schema" do 100 | test "enum in schema with valid value should load correctly" do 101 | schema = 102 | %{ 103 | job_type: [type: EnumTest.JobType] 104 | } 105 | |> Parameter.Schema.compile!() 106 | 107 | assert {:ok, %{job_type: :freelancer}} == 108 | Parameter.load(schema, %{"job_type" => "freelancer"}) 109 | end 110 | 111 | test "enum in schema with valid nested value should load correctly" do 112 | schema = 113 | %{ 114 | job_type: [type: {:array, EnumTest.JobType}] 115 | } 116 | |> Parameter.Schema.compile!() 117 | 118 | assert {:ok, %{job_type: [:freelancer]}} == 119 | Parameter.load(schema, %{"job_type" => ["freelancer"]}) 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /test/parameter/exclude_fields_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parameter.SchemaFieldsTest do 2 | use ExUnit.Case 3 | 4 | alias Parameter.SchemaFields 5 | 6 | describe "field_to_exclude/2" do 7 | test "map if field should be excluded" do 8 | assert :include == SchemaFields.field_to_exclude(:first_name, []) 9 | assert :include == SchemaFields.field_to_exclude(:main_address, [:field]) 10 | assert :exclude == SchemaFields.field_to_exclude(:main_address, [:main_address]) 11 | 12 | assert :exclude == 13 | SchemaFields.field_to_exclude(:main_address, [:field, :main_address, :field]) 14 | 15 | assert :exclude == 16 | SchemaFields.field_to_exclude(:main_address, [ 17 | :field, 18 | :main_address, 19 | :field, 20 | :main_address 21 | ]) 22 | end 23 | 24 | test "map nested field to be excluded" do 25 | assert :include == 26 | SchemaFields.field_to_exclude(:first_name, [{:main_address, [:first_name]}]) 27 | 28 | assert {:exclude, [:street]} == 29 | SchemaFields.field_to_exclude(:main_address, [{:main_address, [:street]}]) 30 | 31 | assert {:exclude, [{:street, [:number]}]} == 32 | SchemaFields.field_to_exclude(:main_address, [ 33 | {:main_address, [{:street, [:number]}]} 34 | ]) 35 | end 36 | 37 | test "sending wrong input should be ignored" do 38 | assert :include == SchemaFields.field_to_exclude(:first_name, :first_name) 39 | assert :include == SchemaFields.field_to_exclude(:first_name, [{:first_name}]) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/parameter/field_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parameter.FieldTest do 2 | use ExUnit.Case 3 | 4 | alias Parameter.Field 5 | 6 | describe "new/1" do 7 | test "initializes a new field struct" do 8 | opts = [ 9 | name: :main_address, 10 | type: :string, 11 | key: "mainAddress", 12 | required: true, 13 | default: "Default" 14 | ] 15 | 16 | assert %Parameter.Field{ 17 | default: "Default", 18 | key: "mainAddress", 19 | name: :main_address, 20 | required: true, 21 | type: :string, 22 | load_default: "Default", 23 | dump_default: "Default" 24 | } == Field.new(opts) 25 | end 26 | 27 | test "fails if name is not an atom" do 28 | opts = [ 29 | name: "main_address", 30 | type: :float, 31 | key: "mainAddress", 32 | required: true, 33 | default: "Hello" 34 | ] 35 | 36 | assert {:error, "invalid atom type"} == Field.new(opts) 37 | end 38 | 39 | test "fails on invalid function" do 40 | opts = [ 41 | name: :address, 42 | type: :float, 43 | on_load: fn val -> val end, 44 | key: "mainAddress", 45 | required: true 46 | ] 47 | 48 | assert {:error, "on_load must be a function with 2 arity"} == Field.new(opts) 49 | end 50 | 51 | test "fails if a default value used at the same time with load_default and dump_default" do 52 | opts = [ 53 | name: :main_address, 54 | type: :string, 55 | key: "mainAddress", 56 | required: true, 57 | default: "Hello", 58 | load_default: "Hello" 59 | ] 60 | 61 | assert {:error, "`default` opts should not be used with `load_default` or `dump_default`"} == 62 | Field.new(opts) 63 | end 64 | 65 | test "load_default and dump_default should work as long don't pass default key" do 66 | opts = [ 67 | name: :main_address, 68 | type: :string, 69 | key: "mainAddress", 70 | required: true, 71 | load_default: "Default", 72 | dump_default: "default" 73 | ] 74 | 75 | assert %Parameter.Field{ 76 | key: "mainAddress", 77 | name: :main_address, 78 | required: true, 79 | type: :string, 80 | load_default: "Default", 81 | dump_default: "default" 82 | } == Field.new(opts) 83 | end 84 | end 85 | 86 | describe "new!/1" do 87 | test "initializes a new field struct" do 88 | opts = [ 89 | name: :main_address, 90 | type: :string, 91 | key: "mainAddress", 92 | required: true, 93 | default: "Default" 94 | ] 95 | 96 | assert %Parameter.Field{ 97 | default: "Default", 98 | key: "mainAddress", 99 | name: :main_address, 100 | required: true, 101 | type: :string, 102 | dump_default: "Default", 103 | load_default: "Default" 104 | } == Field.new!(opts) 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /test/parameter/parametrizable_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parameter.ParametrizableTest do 2 | use ExUnit.Case 3 | 4 | alias Parameter.Parametrizable 5 | 6 | defmodule Param do 7 | use Parametrizable 8 | end 9 | 10 | test "check default parameters of parametrizable" do 11 | assert Param.load("value") == {:ok, "value"} 12 | assert Param.validate("value") == :ok 13 | assert Param.dump("value") == {:ok, "value"} 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/parameter/schema/compiler_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Schema.CompilerTest do 2 | use ExUnit.Case 3 | 4 | alias Parameter.Field 5 | alias Parameter.Schema.Compiler 6 | 7 | describe "compile_schema!/1" do 8 | test "compile empty schema should work" do 9 | assert [] == Compiler.compile_schema!(%{}) 10 | end 11 | 12 | test "compile simple schema" do 13 | assert [ 14 | %Field{ 15 | name: :first_name, 16 | key: "firstName", 17 | required: true 18 | } 19 | ] == 20 | Compiler.compile_schema!(%{ 21 | first_name: [key: "firstName", required: true] 22 | }) 23 | end 24 | 25 | test "invalid inner type should raise error" do 26 | assert_raise( 27 | ArgumentError, 28 | "not a valid inner type, please use `{map, inner_type}` or `{array, inner_type}` for nested associations", 29 | fn -> 30 | Compiler.compile_schema!(%{ 31 | address: [type: {:not_map, %{number: [type: :integer]}}] 32 | }) 33 | end 34 | ) 35 | end 36 | end 37 | 38 | describe "validate_nested_opts/1" do 39 | test "don't compile options for nested fields" do 40 | assert_raise ArgumentError, "on_load cannot be used on nested fields", fn -> 41 | Compiler.validate_nested_opts!(on_load: nil) 42 | end 43 | 44 | assert_raise ArgumentError, "on_dump cannot be used on nested fields", fn -> 45 | Compiler.validate_nested_opts!(on_dump: nil) 46 | end 47 | 48 | assert_raise ArgumentError, "validator cannot be used on nested fields", fn -> 49 | Compiler.validate_nested_opts!(validator: nil) 50 | end 51 | 52 | assert [other_opts: nil] == Compiler.validate_nested_opts!(other_opts: nil) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/parameter/schema_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parameter.SchemaTest do 2 | use ExUnit.Case 3 | 4 | alias Parameter.Field 5 | alias Parameter.Schema 6 | 7 | describe "compile/1" do 8 | test "compile a schema" do 9 | schema = %{ 10 | first_name: [key: "firstName", type: :string, required: true], 11 | address: [required: true, type: {:map, %{street: [type: :string, required: true]}}], 12 | phones: [type: {:array, %{country: [type: :string, required: true]}}] 13 | } 14 | 15 | expected_compiled_schema = [ 16 | %Field{ 17 | name: :first_name, 18 | key: "firstName", 19 | default: :ignore, 20 | load_default: :ignore, 21 | dump_default: :ignore, 22 | type: :string, 23 | required: true, 24 | validator: nil, 25 | virtual: false 26 | }, 27 | %Field{ 28 | name: :address, 29 | key: "address", 30 | default: :ignore, 31 | load_default: :ignore, 32 | dump_default: :ignore, 33 | type: 34 | {:map, 35 | [ 36 | %Field{ 37 | name: :street, 38 | key: "street", 39 | default: :ignore, 40 | load_default: :ignore, 41 | dump_default: :ignore, 42 | type: :string, 43 | required: true, 44 | validator: nil, 45 | virtual: false 46 | } 47 | ]}, 48 | required: true, 49 | validator: nil, 50 | virtual: false 51 | }, 52 | %Field{ 53 | name: :phones, 54 | key: "phones", 55 | default: :ignore, 56 | load_default: :ignore, 57 | dump_default: :ignore, 58 | type: 59 | {:array, 60 | [ 61 | %Field{ 62 | name: :country, 63 | key: "country", 64 | default: :ignore, 65 | load_default: :ignore, 66 | dump_default: :ignore, 67 | type: :string, 68 | required: true, 69 | validator: nil, 70 | virtual: false 71 | } 72 | ]}, 73 | required: false, 74 | validator: nil, 75 | virtual: false 76 | } 77 | ] 78 | 79 | compiled_schema = Schema.compile!(schema) 80 | 81 | assert from_name(compiled_schema, :first_name) == 82 | from_name(expected_compiled_schema, :first_name) 83 | 84 | assert from_name(compiled_schema, :address) == from_name(expected_compiled_schema, :address) 85 | assert from_name(compiled_schema, :phones) == from_name(expected_compiled_schema, :phones) 86 | 87 | assert {:error, %{address: "is required"}} == 88 | Parameter.load(compiled_schema, %{"firstName" => "John"}) 89 | 90 | assert {:ok, %{first_name: "John", address: %{street: "some street"}}} == 91 | Parameter.load(compiled_schema, %{ 92 | "firstName" => "John", 93 | "address" => %{"street" => "some street"} 94 | }) 95 | end 96 | 97 | test "schema with wrong values should return errors" do 98 | schema = %{ 99 | first_name: [key: :first_name, type: :string, required: :atom], 100 | address: [required: true, type: {:not_nested, %{street: [type: :string, required: true]}}], 101 | phones: [type: {:array, %{country: [type: :string, virtual: :not_virtual]}}] 102 | } 103 | 104 | assert_raise ArgumentError, fn -> 105 | Schema.compile!(schema) 106 | end 107 | end 108 | end 109 | 110 | describe "fields/1" do 111 | import Parameter.Schema 112 | 113 | param ModuleSchema do 114 | field :first_name, :string 115 | end 116 | 117 | test "module schema schema fields" do 118 | assert [%Parameter.Field{}] = Schema.fields(__MODULE__.ModuleSchema) 119 | end 120 | 121 | test "runtime schema fields" do 122 | schema = 123 | %{ 124 | first_name: [key: "firstName", type: :string, required: true], 125 | address: [required: true, type: {:map, %{street: [type: :string, required: true]}}], 126 | phones: [type: {:array, %{country: [type: :string, required: true]}}] 127 | } 128 | |> Schema.compile!() 129 | 130 | assert [%Parameter.Field{}, %Parameter.Field{}, %Parameter.Field{}] = Schema.fields(schema) 131 | end 132 | end 133 | 134 | defp from_name(parameters, name) do 135 | Enum.find(parameters, &(&1.name == name)) 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /test/parameter/types/any_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Types.AnyTypeTest do 2 | use ExUnit.Case 3 | doctest Parameter.Types.AnyType 4 | end 5 | -------------------------------------------------------------------------------- /test/parameter/types/atom_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Types.AtomTest do 2 | use ExUnit.Case 3 | doctest Parameter.Types.Atom 4 | end 5 | -------------------------------------------------------------------------------- /test/parameter/types/boolean_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Types.BooleanTest do 2 | use ExUnit.Case 3 | doctest Parameter.Types.Boolean 4 | end 5 | -------------------------------------------------------------------------------- /test/parameter/types/date_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Types.DateTest do 2 | use ExUnit.Case 3 | doctest Parameter.Types.Date 4 | end 5 | -------------------------------------------------------------------------------- /test/parameter/types/datetime_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Types.DateTimeTest do 2 | use ExUnit.Case 3 | doctest Parameter.Types.DateTime 4 | end 5 | -------------------------------------------------------------------------------- /test/parameter/types/float_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Types.FloatTest do 2 | use ExUnit.Case 3 | doctest Parameter.Types.Float 4 | end 5 | -------------------------------------------------------------------------------- /test/parameter/types/naive_datetime_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parameter.Types.NaiveDateTimeTest do 2 | use ExUnit.Case 3 | doctest Parameter.Types.NaiveDateTime 4 | end 5 | -------------------------------------------------------------------------------- /test/parameter/types_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parameter.TypesTest do 2 | use ExUnit.Case 3 | 4 | alias Parameter.Types 5 | 6 | defmodule EnumTest do 7 | import Parameter.Enum 8 | 9 | enum values: [:user_online, :user_offline] 10 | end 11 | 12 | test "base_type?/1" do 13 | assert Types.base_type?(:any) 14 | refute Types.base_type?(:not_type) 15 | end 16 | 17 | test "composite_inner_type?/1" do 18 | assert Types.composite_inner_type?({:array, :any}) 19 | assert Types.composite_inner_type?({:map, :not_type}) 20 | refute Types.composite_inner_type?(:not_type) 21 | end 22 | 23 | describe "load/2" do 24 | test "load any type" do 25 | assert Types.load(:any, "Test") == {:ok, "Test"} 26 | assert Types.load(:any, 1) == {:ok, 1} 27 | assert Types.load(:any, true) == {:ok, true} 28 | assert Types.load(:any, %Decimal{}) == {:ok, %Decimal{}} 29 | end 30 | 31 | test "load string type" do 32 | assert Types.load(:string, "Test") == {:ok, "Test"} 33 | assert Types.load(:string, 1) == {:ok, "1"} 34 | assert Types.load(:string, true) == {:ok, "true"} 35 | assert Types.load(:string, "true") == {:ok, "true"} 36 | end 37 | 38 | test "load atom type" do 39 | assert Types.load(:atom, :test) == {:ok, :test} 40 | assert Types.load(:atom, 1) == {:error, "invalid atom type"} 41 | assert Types.load(:atom, true) == {:ok, true} 42 | assert Types.load(:atom, :SomeValue) == {:ok, :SomeValue} 43 | assert Types.load(:atom, "string type") == {:ok, :"string type"} 44 | end 45 | 46 | test "load boolean type" do 47 | assert Types.load(:boolean, true) == {:ok, true} 48 | assert Types.load(:boolean, false) == {:ok, false} 49 | assert Types.load(:boolean, "True") == {:ok, true} 50 | assert Types.load(:boolean, "FalsE") == {:ok, false} 51 | assert Types.load(:boolean, 1) == {:ok, true} 52 | assert Types.load(:boolean, 0) == {:ok, false} 53 | assert Types.load(:boolean, "1") == {:ok, true} 54 | assert Types.load(:boolean, "0") == {:ok, false} 55 | assert Types.load(:boolean, "other value") == {:error, "invalid boolean type"} 56 | end 57 | 58 | test "load integer type" do 59 | assert Types.load(:integer, 1) == {:ok, 1} 60 | assert Types.load(:integer, "1") == {:ok, 1} 61 | assert Types.load(:integer, "other value") == {:error, "invalid integer type"} 62 | assert Types.load(:integer, 1.5) == {:error, "invalid integer type"} 63 | end 64 | 65 | test "load array type" do 66 | assert Types.load(:array, []) == {:ok, []} 67 | 68 | assert Types.load(:array, [%{"meta" => "data"}, %{"meta" => "data"}]) == 69 | {:ok, [%{"meta" => "data"}, %{"meta" => "data"}]} 70 | 71 | assert Types.load(:array, %{}) == {:error, "invalid array type"} 72 | assert Types.load(:array, nil) == {:error, "invalid array type"} 73 | end 74 | 75 | test "load map type" do 76 | assert Types.load(:map, %{}) == {:ok, %{}} 77 | assert Types.load(:map, %{"meta" => "data"}) == {:ok, %{"meta" => "data"}} 78 | assert Types.load(:map, %{meta: :data}) == {:ok, %{meta: :data}} 79 | assert Types.load(:map, nil) == {:error, "invalid map type"} 80 | assert Types.load(:map, []) == {:error, "invalid map type"} 81 | end 82 | 83 | test "load float type" do 84 | assert Types.load(:float, 1.5) == {:ok, 1.5} 85 | assert Types.load(:float, "1.2") == {:ok, 1.2} 86 | assert Types.load(:float, "other value") == {:error, "invalid float type"} 87 | assert Types.load(:float, 1) == {:ok, 1.0} 88 | assert Types.load(:float, "1") == {:ok, 1.0} 89 | end 90 | 91 | test "load date types" do 92 | assert Types.load(:date, %Date{year: 2020, month: 10, day: 5}) == {:ok, ~D[2020-10-05]} 93 | assert Types.load(:date, {2020, 11, 2}) == {:ok, ~D[2020-11-02]} 94 | assert Types.load(:date, {2020, 13, 5}) == {:error, "invalid date type"} 95 | assert Types.load(:date, ~D[2000-01-01]) == {:ok, ~D[2000-01-01]} 96 | assert Types.load(:date, "2000-01-01") == {:ok, ~D[2000-01-01]} 97 | assert Types.load(:date, "some value") == {:error, "invalid date type"} 98 | 99 | {:ok, time} = Time.new(0, 0, 0, 0) 100 | assert Types.load(:time, time) == {:ok, ~T[00:00:00.000000]} 101 | assert Types.load(:time, ~T[00:00:00.000000]) == {:ok, ~T[00:00:00.000000]} 102 | assert Types.load(:time, {22, 30, 10}) == {:ok, ~T[22:30:10]} 103 | assert Types.load(:time, {-22, 30, 10}) == {:error, "invalid time type"} 104 | assert Types.load(:time, "23:50:07") == {:ok, ~T[23:50:07]} 105 | assert Types.load(:time, ~D[2000-01-01]) == {:error, "invalid time type"} 106 | assert Types.load(:time, "some value") == {:error, "invalid time type"} 107 | 108 | assert Types.load(:datetime, ~U[2018-11-15 10:00:00Z]) == {:ok, ~U[2018-11-15 10:00:00Z]} 109 | assert Types.load(:datetime, ~D[2000-01-01]) == {:error, "invalid datetime type"} 110 | assert Types.load(:datetime, "some value") == {:error, "invalid datetime type"} 111 | 112 | naive_now = NaiveDateTime.local_now() 113 | assert Types.load(:naive_datetime, naive_now) == {:ok, naive_now} 114 | 115 | assert Types.load(:naive_datetime, ~N[2000-01-01 23:00:07]) == 116 | {:ok, ~N[2000-01-01 23:00:07]} 117 | 118 | assert Types.load(:naive_datetime, {{2021, 05, 11}, {22, 30, 10}}) == 119 | {:ok, ~N[2021-05-11 22:30:10]} 120 | 121 | assert Types.load(:naive_datetime, ~D[2000-01-01]) == 122 | {:error, "invalid naive_datetime type"} 123 | 124 | assert Types.load(:naive_datetime, "some value") == {:error, "invalid naive_datetime type"} 125 | end 126 | 127 | test "load decimal type" do 128 | assert Types.load(:decimal, 1.5) == {:ok, Decimal.new("1.5")} 129 | assert Types.load(:decimal, "1.2") == {:ok, Decimal.new("1.2")} 130 | assert Types.load(:decimal, "1.2letters") == {:error, "invalid decimal type"} 131 | assert Types.load(:decimal, "other value") == {:error, "invalid decimal type"} 132 | assert Types.load(:decimal, 1) == {:ok, Decimal.new("1")} 133 | assert Types.load(:decimal, "1") == {:ok, Decimal.new("1")} 134 | end 135 | 136 | test "load enum type" do 137 | assert Types.load(EnumTest, true) == {:error, "invalid enum type"} 138 | assert Types.load(EnumTest, "user_online") == {:ok, :user_online} 139 | assert Types.load(EnumTest, :user_online) == {:error, "invalid enum type"} 140 | assert Types.load(EnumTest, "userOnline") == {:error, "invalid enum type"} 141 | assert Types.load(EnumTest, "user_offline") == {:ok, :user_offline} 142 | end 143 | end 144 | 145 | describe "dump/2" do 146 | test "dump any type" do 147 | assert Types.dump(:any, "Test") == {:ok, "Test"} 148 | assert Types.dump(:any, 1) == {:ok, 1} 149 | assert Types.dump(:any, true) == {:ok, true} 150 | assert Types.dump(:any, %Decimal{}) == {:ok, %Decimal{}} 151 | end 152 | 153 | test "dump string type" do 154 | assert Types.dump(:string, "Test") == {:ok, "Test"} 155 | assert Types.dump(:string, 1) == {:ok, "1"} 156 | assert Types.dump(:string, true) == {:ok, "true"} 157 | assert Types.dump(:string, "true") == {:ok, "true"} 158 | end 159 | 160 | test "dump atom type" do 161 | assert Types.dump(:atom, :test) == {:ok, :test} 162 | assert Types.dump(:atom, 1) == {:error, "invalid atom type"} 163 | assert Types.dump(:atom, true) == {:ok, true} 164 | assert Types.dump(:atom, :SomeValue) == {:ok, :SomeValue} 165 | assert Types.dump(:atom, nil) == {:error, "invalid atom type"} 166 | end 167 | 168 | test "dump boolean type" do 169 | assert Types.dump(:boolean, true) == {:ok, true} 170 | assert Types.dump(:boolean, "true") == {:error, "invalid boolean type"} 171 | assert Types.dump(:boolean, 2.5) == {:error, "invalid boolean type"} 172 | end 173 | 174 | test "dump integer type" do 175 | assert Types.dump(:integer, 1) == {:ok, 1} 176 | assert Types.dump(:integer, "1") == {:ok, 1} 177 | assert Types.dump(:integer, 1.5) == {:error, "invalid integer type"} 178 | end 179 | 180 | test "dump array type" do 181 | assert Types.dump(:array, []) == {:ok, []} 182 | assert Types.dump(:array, [%{"meta" => "data"}]) == {:ok, [%{"meta" => "data"}]} 183 | assert Types.dump(:array, nil) == {:error, "invalid array type"} 184 | assert Types.dump(:array, %{}) == {:error, "invalid array type"} 185 | end 186 | 187 | test "dump map type" do 188 | assert Types.dump(:map, %{}) == {:ok, %{}} 189 | assert Types.dump(:map, %{"meta" => "data"}) == {:ok, %{"meta" => "data"}} 190 | assert Types.dump(:map, %{meta: :data}) == {:ok, %{meta: :data}} 191 | assert Types.dump(:map, nil) == {:error, "invalid map type"} 192 | assert Types.dump(:map, []) == {:error, "invalid map type"} 193 | end 194 | 195 | test "dump float type" do 196 | assert Types.dump(:float, 1.5) == {:ok, 1.5} 197 | assert Types.dump(:float, 1) == {:ok, 1.0} 198 | assert Types.dump(:float, "string") == {:error, "invalid float type"} 199 | end 200 | 201 | test "dump date types" do 202 | assert Types.dump(:date, %Date{year: 2020, month: 10, day: 5}) == 203 | {:ok, %Date{year: 2020, month: 10, day: 5}} 204 | 205 | assert Types.dump(:date, ~D[2000-01-01]) == {:ok, ~D[2000-01-01]} 206 | assert Types.dump(:date, "2000-01-01") == {:ok, ~D[2000-01-01]} 207 | 208 | {:ok, time} = Time.new(0, 0, 0, 0) 209 | assert Types.dump(:time, time) == {:ok, time} 210 | assert Types.dump(:time, ~D[2000-01-01]) == {:error, "invalid time type"} 211 | 212 | assert Types.dump(:datetime, ~U[2018-11-15 10:00:00Z]) == {:ok, ~U[2018-11-15 10:00:00Z]} 213 | assert Types.dump(:datetime, ~D[2000-01-01]) == {:error, "invalid datetime type"} 214 | 215 | local_now = NaiveDateTime.local_now() 216 | assert Types.dump(:naive_datetime, local_now) == {:ok, local_now} 217 | 218 | assert Types.dump(:naive_datetime, ~N[2000-01-01 23:00:07]) == 219 | {:ok, ~N[2000-01-01 23:00:07]} 220 | 221 | assert Types.dump(:naive_datetime, ~D[2000-01-01]) == 222 | {:error, "invalid naive_datetime type"} 223 | end 224 | 225 | test "dump enum type" do 226 | assert Types.dump(EnumTest, true) == {:error, "invalid enum type"} 227 | assert Types.dump(EnumTest, "user_online") == {:error, "invalid enum type"} 228 | assert Types.dump(EnumTest, :user_online) == {:ok, "user_online"} 229 | assert Types.dump(EnumTest, "userOnline") == {:error, "invalid enum type"} 230 | assert Types.dump(EnumTest, :user_offline) == {:ok, "user_offline"} 231 | end 232 | end 233 | 234 | describe "validate/2" do 235 | test "validate any type" do 236 | assert Types.validate(:any, "Test") == :ok 237 | assert Types.validate(:any, 1) == :ok 238 | assert Types.validate(:any, %Decimal{}) == :ok 239 | assert Types.validate(:any, false) == :ok 240 | end 241 | 242 | test "validate string type" do 243 | assert Types.validate(:string, "Test") == :ok 244 | assert Types.validate(:string, 1) == {:error, "invalid string type"} 245 | assert Types.validate(:string, true) == {:error, "invalid string type"} 246 | assert Types.validate(:string, "true") == :ok 247 | end 248 | 249 | test "validate atom type" do 250 | assert Types.validate(:atom, :test) == :ok 251 | assert Types.validate(:atom, 1) == {:error, "invalid atom type"} 252 | assert Types.validate(:atom, true) == :ok 253 | assert Types.validate(:atom, :SomeValue) == :ok 254 | assert Types.validate(:atom, nil) == {:error, "invalid atom type"} 255 | end 256 | 257 | test "validate boolean type" do 258 | assert Types.validate(:boolean, true) == :ok 259 | assert Types.validate(:boolean, "true") == {:error, "invalid boolean type"} 260 | assert Types.validate(:boolean, 2.5) == {:error, "invalid boolean type"} 261 | end 262 | 263 | test "validate integer type" do 264 | assert Types.validate(:integer, 1) == :ok 265 | assert Types.validate(:integer, "1") == {:error, "invalid integer type"} 266 | assert Types.validate(:integer, 1.5) == {:error, "invalid integer type"} 267 | end 268 | 269 | test "validate array type" do 270 | assert Types.validate(:array, []) == :ok 271 | assert Types.validate(:array, [%{"meta" => "data"}]) == :ok 272 | assert Types.validate(:array, nil) == {:error, "invalid array type"} 273 | assert Types.validate(:array, %{}) == {:error, "invalid array type"} 274 | end 275 | 276 | test "validate map type" do 277 | assert Types.validate(:map, %{}) == :ok 278 | assert Types.validate(:map, %{"meta" => "data"}) == :ok 279 | assert Types.validate(:map, %{meta: :data}) == :ok 280 | assert Types.validate(:map, nil) == {:error, "invalid map type"} 281 | assert Types.validate(:map, []) == {:error, "invalid map type"} 282 | end 283 | 284 | test "validate float type" do 285 | assert Types.validate(:float, 1.5) == :ok 286 | assert Types.validate(:float, 1) == {:error, "invalid float type"} 287 | end 288 | 289 | test "validate date types" do 290 | assert Types.validate(:date, %Date{year: 2020, month: 10, day: 5}) == :ok 291 | assert Types.validate(:date, ~D[2000-01-01]) == :ok 292 | assert Types.validate(:date, "2000-01-01") == {:error, "invalid date type"} 293 | 294 | {:ok, time} = Time.new(0, 0, 0, 0) 295 | assert Types.validate(:time, time) == :ok 296 | assert Types.validate(:time, ~D[2000-01-01]) == {:error, "invalid time type"} 297 | 298 | assert Types.validate(:datetime, ~U[2018-11-15 10:00:00Z]) == :ok 299 | assert Types.validate(:datetime, ~D[2000-01-01]) == {:error, "invalid datetime type"} 300 | 301 | assert Types.validate(:naive_datetime, NaiveDateTime.local_now()) == :ok 302 | assert Types.validate(:naive_datetime, ~N[2000-01-01 23:00:07]) == :ok 303 | 304 | assert Types.validate(:naive_datetime, ~D[2000-01-01]) == 305 | {:error, "invalid naive_datetime type"} 306 | end 307 | 308 | test "validate decimal type" do 309 | assert Types.validate(:decimal, Decimal.new("1.5")) == :ok 310 | assert Types.validate(:decimal, "1.2") == {:error, "invalid decimal type"} 311 | assert Types.validate(:decimal, "1.2letters") == {:error, "invalid decimal type"} 312 | assert Types.validate(:decimal, "other value") == {:error, "invalid decimal type"} 313 | assert Types.validate(:decimal, 1) == {:error, "invalid decimal type"} 314 | assert Types.validate(:decimal, "1") == {:error, "invalid decimal type"} 315 | end 316 | 317 | test "validate enum type" do 318 | assert Types.validate(EnumTest, true) == {:error, "invalid enum type"} 319 | assert Types.validate(EnumTest, "user_online") == {:error, "invalid enum type"} 320 | assert Types.validate(EnumTest, :user_online) == :ok 321 | assert Types.validate(EnumTest, "userOnline") == {:error, "invalid enum type"} 322 | assert Types.validate(EnumTest, :user_offline) == :ok 323 | end 324 | 325 | test "validate map with inner type" do 326 | assert Types.validate({:map, :string}, %{}) == :ok 327 | assert Types.validate({:map, :string}, %{key: "value", other_key: "other value"}) == :ok 328 | 329 | assert Types.validate({:map, :string}, %{key: "value", other_key: 22}) == 330 | {:error, "invalid string type"} 331 | 332 | assert Types.validate({:map, :float}, "21") == {:error, "invalid map type"} 333 | assert Types.validate({:map, {:array, :float}}, "21") == {:error, "invalid map type"} 334 | assert Types.validate({:map, {:array, :float}}, %{k: [1.5]}) == :ok 335 | end 336 | 337 | test "validate array with inner type" do 338 | assert Types.validate({:array, :string}, []) == :ok 339 | assert Types.validate({:array, :string}, ["value", "other value"]) == :ok 340 | 341 | assert Types.validate({:array, :float}, ["value", "other value"]) == 342 | {:error, "invalid float type"} 343 | 344 | assert Types.validate({:array, :float}, "21") == {:error, "invalid array type"} 345 | assert Types.validate({:array, {:array, :float}}, "21") == {:error, "invalid array type"} 346 | assert Types.validate({:array, {:array, :float}}, [[1.5, 5.5], [1.2]]) == :ok 347 | end 348 | end 349 | 350 | test "validate!/2" do 351 | assert Types.validate!(:any, %{}) == :ok 352 | 353 | assert_raise ArgumentError, "invalid map type", fn -> 354 | Types.validate!({:map, :float}, "21") 355 | end 356 | end 357 | end 358 | -------------------------------------------------------------------------------- /test/parameter/validators_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Parameter.ValidatorsTest do 2 | use ExUnit.Case 3 | 4 | alias Parameter.Validators 5 | 6 | test "email/1" do 7 | assert Validators.email("user@gmail.com") == :ok 8 | assert Validators.email("otherUser@hotmail.com") == :ok 9 | assert Validators.email("other.value@outlook.com") == :ok 10 | assert Validators.email("") == {:error, "is invalid"} 11 | assert Validators.email("@") == {:error, "is invalid"} 12 | assert Validators.email("user@user") == {:error, "is invalid"} 13 | end 14 | 15 | test "equal/2" do 16 | assert Validators.equal("value", to: "value") == :ok 17 | assert Validators.equal(25, to: 25) == :ok 18 | assert Validators.equal(25, to: "value") == {:error, "is invalid"} 19 | assert Validators.equal(25, to: 15) == {:error, "is invalid"} 20 | end 21 | 22 | test "length/2" do 23 | assert Validators.length(15, min: 12, max: 16) == :ok 24 | assert Validators.length("value", min: "sm", max: "value_bigger") == :ok 25 | assert Validators.length(25, min: 12, max: 16) == {:error, "is invalid"} 26 | assert Validators.length(5, min: 6, max: 12) == {:error, "is invalid"} 27 | 28 | assert Validators.length("value", min: "value_big", max: "value_bigger") == 29 | {:error, "is invalid"} 30 | 31 | assert Validators.length(3, min: 5) == {:error, "is invalid"} 32 | assert Validators.length(10, min: 6) == :ok 33 | 34 | assert Validators.length(21, max: 15) == {:error, "is invalid"} 35 | assert Validators.length(55, max: 60) == :ok 36 | end 37 | 38 | test "one_of/2" do 39 | assert Validators.one_of(15, options: [15, 5]) == :ok 40 | assert Validators.one_of(5, options: [15, 5]) == :ok 41 | assert Validators.one_of(2, options: [15, 5]) == {:error, "is invalid"} 42 | assert Validators.one_of("value", options: ["value", "otherValue"]) == :ok 43 | assert Validators.one_of("otherValue", options: ["value", "otherValue"]) == :ok 44 | 45 | assert Validators.one_of("notIncluded", options: ["value", "otherValue"]) == 46 | {:error, "is invalid"} 47 | end 48 | 49 | test "none_of/2" do 50 | assert Validators.none_of(15, options: [15, 5]) == {:error, "is invalid"} 51 | assert Validators.none_of(5, options: [15, 5]) == {:error, "is invalid"} 52 | assert Validators.none_of(2, options: [15, 5]) == :ok 53 | assert Validators.none_of("value", options: ["value", "otherValue"]) == {:error, "is invalid"} 54 | 55 | assert Validators.none_of("otherValue", options: ["value", "otherValue"]) == 56 | {:error, "is invalid"} 57 | 58 | assert Validators.none_of("notIncluded", options: ["value", "otherValue"]) == :ok 59 | end 60 | 61 | test "regex/2" do 62 | assert Validators.regex("foo", regex: ~r/foo/) == :ok 63 | assert Validators.regex("foobar", regex: ~r/foo/) == :ok 64 | assert Validators.regex("bar", regex: ~r/foo/) == {:error, "is invalid"} 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------