├── test ├── test_helper.exs └── jeaux_test.exs ├── .travis.yml ├── .gitignore ├── lib ├── jeaux │ ├── schema.ex │ └── params.ex └── jeaux.ex ├── LICENSE ├── mix.exs ├── config └── config.exs ├── README.md └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.2.0 4 | - 1.2.2 5 | - 1.3.0 6 | - 1.3.4 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | -------------------------------------------------------------------------------- /lib/jeaux/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule Jeaux.Schema do 2 | @moduledoc false 3 | 4 | def normalize_schema(dict) do 5 | Enum.reduce(dict, %{}, fn {k,v}, acc -> 6 | Map.merge(normalize_field({k, v}), acc) 7 | end) 8 | end 9 | 10 | defp normalize_field({k, v}) when is_map(v), do: %{k => normalize_schema(v)} 11 | defp normalize_field({k, v}) do 12 | required = String.ends_with?("#{k}", "!") 13 | name = 14 | "#{k}" 15 | |> String.replace_trailing("!", "") 16 | |> String.to_atom 17 | 18 | case is_atom(v) do 19 | true -> %{name => [type: v, required: required]} 20 | false -> %{name => [required: required] ++ v} 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/jeaux.ex: -------------------------------------------------------------------------------- 1 | defmodule Jeaux do 2 | @moduledoc """ 3 | This is the main and only access point for the module. 4 | """ 5 | alias Jeaux.Params 6 | alias Jeaux.Schema 7 | 8 | @doc """ 9 | Validates a map against a schema. 10 | 11 | returns `{:ok, validated_params}` or `{:error, error_message}` 12 | 13 | for example: 14 | ```elixir 15 | params = %{"limit" => "2", "sort_by" => "name", "sort_dir" => "asc"} 16 | schema = %{ 17 | limit: [type: :integer, default: 10, min: 1, max: 100], 18 | offset: [type: :integer, default: 0, min: 0], 19 | sort_by: [type: :string, default: "created_at"], 20 | sort_dir: [type: :string, default: "asc"] 21 | } 22 | 23 | {:ok, valid_params} = Jeaux.validate(params, schema) 24 | ``` 25 | """ 26 | def validate(params, schema), do: Params.compare(params, Schema.normalize_schema(schema)) 27 | end 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Mathew Gardner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Jeaux.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :jeaux, 6 | version: "0.7.0", 7 | elixir: "~> 1.2", 8 | description: description, 9 | package: package, 10 | build_embedded: Mix.env == :prod, 11 | start_permanent: Mix.env == :prod, 12 | test_coverage: [tool: ExCoveralls], 13 | deps: deps] 14 | end 15 | 16 | def application, do: [applications: applications] 17 | 18 | defp applications, do: [] 19 | 20 | defp deps do 21 | [ 22 | {:proper_case, "~> 1.0"}, 23 | {:ex_doc, ">= 0.0.0", only: :dev}, 24 | {:excoveralls, "~> 0.5", only: :test}, 25 | {:credo, "~> 0.4", only: [:dev, :test]} 26 | ] 27 | end 28 | 29 | defp description do 30 | """ 31 | A library for validating http params and queries 32 | """ 33 | end 34 | 35 | defp package do 36 | [ 37 | maintainers: ["Zac Barnes "], 38 | files: ["lib", "mix.exs", "README.md", "LICENSE",], 39 | licenses: ["MIT"], 40 | links: %{ 41 | "GitHub" => "http://github.com/zbarnes757/jeaux" 42 | } 43 | ] 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :jeaux, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:jeaux, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jeaux 2 | 3 | [![Hex.pm](https://img.shields.io/hexpm/v/jeaux.svg)](https://hex.pm/packages/jeaux) 4 | [![Build Status](https://travis-ci.org/zbarnes757/jeaux.svg?branch=master)](https://travis-ci.org/zbarnes757/jeaux) 5 | 6 | Jeaux is a light and easy schema validator. 7 | 8 | ## Installation 9 | 10 | [Available in Hex](https://hex.pm/packages/jeaux), the package can be installed as: 11 | 12 | 1. Add `jeaux` to your list of dependencies in `mix.exs`: 13 | 14 | ```elixir 15 | def deps do 16 | [{:jeaux, "~> 0.7.0"}] 17 | end 18 | ``` 19 | 20 | Example: 21 | 22 | ```elixir 23 | # web/controllers/my_controller.ex 24 | @params_schema %{ 25 | lat!: :float, 26 | lon!: :float, 27 | radius: [type: :integer, default: 100, min: 1, max: 100], 28 | is_point: :boolean, 29 | properties: %{ 30 | name: :string 31 | } 32 | } 33 | 34 | def index(conn, params) do 35 | case Jeaux.validate(params, @params_schema) do 36 | {:ok, valid_params} -> do_your_thing(valid_params) 37 | {:error, message} -> Explode.bad_request(conn, message) 38 | end 39 | end 40 | ``` 41 | 42 | Using a `!` in your key denotes it is required. 43 | 44 | Currently, the following keys are valid: 45 | * `type:` with `:integer`, `:string`, `:boolean`, `:guid` (`:string` type is implied), `:float`, or `:list` as applicable types 46 | * `default:` Sets a default value if none is currently provided in params 47 | * `min:` Minimum value a param can have 48 | * `max:` Maximum value a param can have 49 | * `valid:` Values that are valid options. Can be a single item or a list. 50 | 51 | For `:list` types, if passed an array from a query string (a la `foo=1,2,3`), it will parse into a list (`['1', '2', '3']`). I am still working on finding a way to coerce these into the types they should be. 52 | 53 | Params must be a map but the keys can be strings or atoms and in camelCase or snake_case. The keys of the object to be validated will be converted to snake_case prior to validation. Therefore the validation should use snake_case atoms. Additionally, the result will always be returned as snake_case atom keys as well. 54 | 55 | If you want to contribute, feel free to fork and open a pr. 56 | 57 | Checkout [Explode](https://github.com/pkinney/explode) for an easy utility for responding with standard HTTP/JSON error payloads in Plug- and Phoenix-based applications. 58 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"bunt": {:hex, :bunt, "0.1.6", "5d95a6882f73f3b9969fdfd1953798046664e6f77ec4e486e6fafc7caad97c6f", [:mix], []}, 2 | "certifi": {:hex, :certifi, "0.4.0", "a7966efb868b179023618d29a407548f70c52466bf1849b9e8ebd0e34b7ea11f", [:rebar3], []}, 3 | "credo": {:hex, :credo, "0.4.12", "f5e1973405ea25c6e64959fb0b6bf92950147a0278cc2a002a491b45f78f7b87", [:mix], [{:bunt, "~> 0.1.6", [hex: :bunt, optional: false]}]}, 4 | "decimal": {:hex, :decimal, "1.1.2", "79a769d4657b2d537b51ef3c02d29ab7141d2b486b516c109642d453ee08e00c", [:mix], []}, 5 | "earmark": {:hex, :earmark, "1.0.2", "a0b0904d74ecc14da8bd2e6e0248e1a409a2bc91aade75fcf428125603de3853", [:mix], []}, 6 | "ecto": {:hex, :ecto, "2.0.4", "03fd3b9aa508b1383eb38c00ac389953ed22af53811aa2e504975a3e814a8d97", [:mix], [{:db_connection, "~> 1.0-rc.2", [hex: :db_connection, optional: true]}, {:decimal, "~> 1.0", [hex: :decimal, optional: false]}, {:mariaex, "~> 0.7.7", [hex: :mariaex, optional: true]}, {:poison, "~> 1.5 or ~> 2.0", [hex: :poison, optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, optional: false]}, {:postgrex, "~> 0.11.2", [hex: :postgrex, optional: true]}, {:sbroker, "~> 1.0-beta", [hex: :sbroker, optional: true]}]}, 7 | "ex_doc": {:hex, :ex_doc, "0.14.3", "e61cec6cf9731d7d23d254266ab06ac1decbb7651c3d1568402ec535d387b6f7", [:mix], [{:earmark, "~> 1.0", [hex: :earmark, optional: false]}]}, 8 | "excoveralls": {:hex, :excoveralls, "0.5.6", "35a903f6f78619ee7f951448dddfbef094b3a0d8581657afaf66465bc930468e", [:mix], [{:exjsx, "~> 3.0", [hex: :exjsx, optional: false]}, {:hackney, ">= 0.12.0", [hex: :hackney, optional: false]}]}, 9 | "exjsx": {:hex, :exjsx, "3.2.1", "1bc5bf1e4fd249104178f0885030bcd75a4526f4d2a1e976f4b428d347614f0f", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, optional: false]}]}, 10 | "hackney": {:hex, :hackney, "1.6.1", "ddd22d42db2b50e6a155439c8811b8f6df61a4395de10509714ad2751c6da817", [:rebar3], [{:certifi, "0.4.0", [hex: :certifi, optional: false]}, {:idna, "1.2.0", [hex: :idna, optional: false]}, {:metrics, "1.0.1", [hex: :metrics, optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, optional: false]}, {:ssl_verify_fun, "1.1.0", [hex: :ssl_verify_fun, optional: false]}]}, 11 | "idna": {:hex, :idna, "1.2.0", "ac62ee99da068f43c50dc69acf700e03a62a348360126260e87f2b54eced86b2", [:rebar3], []}, 12 | "jsx": {:hex, :jsx, "2.8.0", "749bec6d205c694ae1786d62cea6cc45a390437e24835fd16d12d74f07097727", [:mix, :rebar], []}, 13 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], []}, 14 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], []}, 15 | "params": {:hex, :params, "2.0.1", "10cbc61dcdeb0fdc7d8f28788867da83038f8b6f494094547e581f637126e06b", [:mix], [{:ecto, "~> 2.0.1", [hex: :ecto, optional: false]}]}, 16 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], []}, 17 | "proper_case": {:hex, :proper_case, "1.0.0", "ee003e3b5e0cc17fafed8d6b422b10741cdc291a1b71b49bfe3afdcf04601e6b", [:mix], []}, 18 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.0", "edee20847c42e379bf91261db474ffbe373f8acb56e9079acb6038d4e0bf414f", [:make, :rebar], []}} 19 | -------------------------------------------------------------------------------- /test/jeaux_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JeauxTest do 2 | use ExUnit.Case 3 | doctest Jeaux 4 | 5 | test "applies defaults to params" do 6 | params = %{} 7 | schema = %{foo: [default: "bar"]} 8 | 9 | {status, result} = Jeaux.validate(params, schema) 10 | 11 | assert status === :ok 12 | assert result[:foo] === "bar" 13 | end 14 | 15 | test "does not apply defaults when param is present" do 16 | params = %{"limit" => "2", "sort_by" => "name", "sort_dir" => "asc"} 17 | schema = %{ 18 | limit: [type: :integer, default: 10, min: 1, max: 100], 19 | offset: [type: :integer, default: 0, min: 0], 20 | sort_by: [type: :string, default: "created_at"], 21 | sort_dir: [type: :string, default: "asc"] 22 | } 23 | 24 | {status, result} = Jeaux.validate(params, schema) 25 | 26 | assert status === :ok 27 | assert result[:sort_by] === "name" 28 | end 29 | 30 | test "throws error when required params not present" do 31 | params = %{} 32 | schema = %{foo!: :string} 33 | 34 | {status, message} = Jeaux.validate(params, schema) 35 | 36 | assert status === :error 37 | assert message === "foo is required." 38 | end 39 | 40 | test "returns params when required params present" do 41 | params = %{foo: "bar"} 42 | schema = %{foo!: :string} 43 | 44 | {status, result} = Jeaux.validate(params, schema) 45 | 46 | assert status === :ok 47 | assert result[:foo] === "bar" 48 | end 49 | 50 | test "converts param to string when able" do 51 | params = %{foo: 1} 52 | schema = %{foo!: :string} 53 | 54 | {status, result} = Jeaux.validate(params, schema) 55 | 56 | assert status === :ok 57 | assert result[:foo] === "1" 58 | end 59 | 60 | test "converts integer param to float when able" do 61 | params = %{foo: 1} 62 | schema = %{foo!: :float} 63 | 64 | {status, result} = Jeaux.validate(params, schema) 65 | 66 | assert status === :ok 67 | assert result[:foo] === 1.0 68 | end 69 | 70 | test "converts string param to float when able" do 71 | params = %{foo: "1.0"} 72 | schema = %{foo!: :float} 73 | 74 | {status, result} = Jeaux.validate(params, schema) 75 | 76 | assert status === :ok 77 | assert result[:foo] === 1.0 78 | end 79 | 80 | test "throws error when param should be float but isn't" do 81 | params = %{foo: "cat"} 82 | schema = %{foo!: :float} 83 | 84 | {status, message} = Jeaux.validate(params, schema) 85 | 86 | assert status === :error 87 | assert message === "foo must be a float." 88 | end 89 | 90 | test "converts float param to integer when able" do 91 | params = %{foo: 1.0} 92 | schema = %{foo!: :integer} 93 | 94 | {status, result} = Jeaux.validate(params, schema) 95 | 96 | assert status === :ok 97 | assert result[:foo] === 1 98 | end 99 | 100 | test "throws error when param should be integer but isn't" do 101 | params = %{foo: "bar"} 102 | schema = %{foo!: :integer} 103 | 104 | {status, message} = Jeaux.validate(params, schema) 105 | 106 | assert status === :error 107 | assert message === "foo must be an integer." 108 | end 109 | 110 | test "throws error when param is less than min limit" do 111 | params = %{foo: 1} 112 | schema = %{foo!: [type: :integer, min: 2]} 113 | 114 | {status, message} = Jeaux.validate(params, schema) 115 | 116 | assert status === :error 117 | assert message === "foo must be greater than or equal to 2" 118 | end 119 | 120 | test "throws error when param is more than max limit" do 121 | params = %{foo: 2} 122 | schema = %{foo!: [type: :integer, max: 1]} 123 | 124 | {status, message} = Jeaux.validate(params, schema) 125 | 126 | assert status === :error 127 | assert message === "foo must be less than or equal to 1" 128 | end 129 | 130 | test "throws error when param is not in schema" do 131 | params = %{foo: 1, bar: "not in schema"} 132 | schema = %{foo!: [type: :integer, max: 1]} 133 | 134 | {status, message} = Jeaux.validate(params, schema) 135 | 136 | assert status === :error 137 | assert message === "bar is not a valid parameter" 138 | end 139 | 140 | test "works with strings as map keys" do 141 | params = %{"id" => "an-id", "radius" => "100"} 142 | schema = %{ 143 | "id!" => :string, 144 | "radius" => [type: :integer, default: 100, min: 1, max: 100] 145 | } 146 | 147 | {status, result} = Jeaux.validate(params, schema) 148 | 149 | assert status === :ok 150 | assert result[:id] === "an-id" 151 | assert result[:radius] === 100 152 | end 153 | 154 | test "works with multiple params" do 155 | params = %{lat: 0.0, lon: 0.0} 156 | schema = %{lat!: :float, lon!: :float, radius: [type: :integer, default: 100, min: 1, max: 100]} 157 | 158 | {status, result} = Jeaux.validate(params, schema) 159 | 160 | assert status === :ok 161 | assert result[:lat] === 0.0 162 | assert result[:lon] === 0.0 163 | assert result[:radius] === 100 164 | end 165 | 166 | test "makes sure a value is valid" do 167 | params = %{foo: 1} 168 | schema = %{foo!: [type: :integer, valid: 1]} 169 | 170 | {status, result} = Jeaux.validate(params, schema) 171 | 172 | assert status === :ok 173 | assert result[:foo] === 1 174 | end 175 | 176 | test "makes sure a value is valid with a list of options" do 177 | params = %{foo: 1} 178 | schema = %{foo!: [type: :integer, valid: [1, 2]]} 179 | 180 | {status, result} = Jeaux.validate(params, schema) 181 | 182 | assert status === :ok 183 | assert result[:foo] === 1 184 | end 185 | 186 | test "throws error when param is not a valid value" do 187 | params = %{foo: 1} 188 | schema = %{foo!: [type: :integer, valid: 2]} 189 | 190 | {status, message} = Jeaux.validate(params, schema) 191 | 192 | assert status === :error 193 | assert message === "foo is not a valid value." 194 | end 195 | 196 | test "throws error when param is not a valid value with a list of valids" do 197 | params = %{foo: 1} 198 | schema = %{foo!: [type: :integer, valid: [2, 3]]} 199 | 200 | {status, message} = Jeaux.validate(params, schema) 201 | 202 | assert status === :error 203 | assert message === "foo is not a valid value." 204 | end 205 | 206 | test "handles nested schema validation" do 207 | params = %{ 208 | "type" => "Feature", 209 | "geometry" => %{ 210 | "type" => "Point", 211 | "coordinates" => [125.6, 10.1] 212 | }, 213 | "properties" => %{ 214 | "name": "Dinagat Islands" 215 | } 216 | } 217 | 218 | schema = %{ 219 | type!: :string, 220 | geometry: %{ 221 | type!: :string, 222 | coordinates: :list 223 | }, 224 | properties: %{ 225 | name: :string 226 | } 227 | } 228 | 229 | {status, result} = Jeaux.validate(params, schema) 230 | 231 | assert status === :ok 232 | assert result[:geometry][:type] === "Point" 233 | end 234 | 235 | test "handles errors in nested schema validation" do 236 | params = %{ 237 | "type" => "Feature", 238 | "geometry" => %{ 239 | "type" => "Point", 240 | "coordinates" => 90.0 241 | }, 242 | "properties" => %{ 243 | "name": "Dinagat Islands" 244 | } 245 | } 246 | 247 | schema = %{ 248 | type!: :string, 249 | geometry: %{ 250 | type!: :string, 251 | coordinates: :list 252 | }, 253 | properties: %{ 254 | name: :string 255 | } 256 | } 257 | 258 | {status, message} = Jeaux.validate(params, schema) 259 | 260 | assert status === :error 261 | assert message === "coordinates must be a list." 262 | end 263 | 264 | test "coerces a query string array into list type" do 265 | params = %{"array" => "-96.7915,32.78,-96.785,32.783"} 266 | schema = %{array!: :list} 267 | 268 | {status, result} = Jeaux.validate(params, schema) 269 | 270 | assert status === :ok 271 | assert result[:array] === ["-96.7915", "32.78", "-96.785", "32.783"] 272 | end 273 | 274 | test "asserts a value is a valid guid" do 275 | params = %{foo: "0e14f2db-ff0b-43bd-b88c-01b9f226778f"} 276 | schema = %{foo!: :guid} 277 | 278 | {status, result} = Jeaux.validate(params, schema) 279 | 280 | assert status === :ok 281 | assert result[:foo] === "0e14f2db-ff0b-43bd-b88c-01b9f226778f" 282 | 283 | params2 = %{foo: "{D1A5279D-B27D-4CD4-A05E-EFDD53D08E8D}"} 284 | 285 | {status, result} = Jeaux.validate(params2, schema) 286 | 287 | assert status === :ok 288 | assert result[:foo] === "{D1A5279D-B27D-4CD4-A05E-EFDD53D08E8D}" 289 | end 290 | 291 | test "throws an error when a value is not a valid guid" do 292 | params = %{foo: "some-string"} 293 | schema = %{foo!: :guid} 294 | 295 | {status, message} = Jeaux.validate(params, schema) 296 | 297 | assert status === :error 298 | assert message === "foo must be in valid guid format." 299 | end 300 | 301 | test "applies defaults when value is nil" do 302 | params = %{foo: nil} 303 | schema = %{foo: [default: "bar"]} 304 | 305 | {status, result} = Jeaux.validate(params, schema) 306 | 307 | assert status === :ok 308 | assert result[:foo] === "bar" 309 | end 310 | 311 | test "accepts boolean types" do 312 | params = %{foo: true} 313 | schema = %{foo: :boolean} 314 | 315 | {status, result} = Jeaux.validate(params, schema) 316 | 317 | assert status === :ok 318 | assert result[:foo] === true 319 | end 320 | 321 | test "coerces boolean types if in schema" do 322 | params = %{foo: "true"} 323 | schema = %{foo: :boolean} 324 | 325 | {status, result} = Jeaux.validate(params, schema) 326 | 327 | assert status === :ok 328 | assert result[:foo] === true 329 | end 330 | 331 | test "throws error if not a boolean" do 332 | params = %{foo: 123} 333 | schema = %{foo: :boolean} 334 | 335 | {status, message} = Jeaux.validate(params, schema) 336 | 337 | assert status === :error 338 | assert message === "foo must be a boolean." 339 | end 340 | 341 | test "accepts camelCase params and converts to snake_case" do 342 | params = %{"limit" => "2", "sortBy" => "name", "sortDir" => "asc"} 343 | schema = %{ 344 | limit: [type: :integer, default: 10, min: 1, max: 100], 345 | offset: [type: :integer, default: 0, min: 0], 346 | sort_by: [type: :string, default: "created_at"], 347 | sort_dir: [type: :string, default: "asc"] 348 | } 349 | 350 | {status, result} = Jeaux.validate(params, schema) 351 | 352 | assert status === :ok 353 | assert result === %{limit: 2, offset: 0, sort_by: "name", sort_dir: "asc"} 354 | end 355 | end 356 | -------------------------------------------------------------------------------- /lib/jeaux/params.ex: -------------------------------------------------------------------------------- 1 | defmodule Jeaux.Params do 2 | @moduledoc false 3 | 4 | def compare(params, schema) do 5 | params 6 | |> ProperCase.to_snake_case 7 | |> keys_to_atoms 8 | |> apply_defaults(schema) 9 | |> validate_required(schema) 10 | |> parse_into_types(schema) 11 | |> validate_types(schema) 12 | |> validate_min(schema) 13 | |> validate_max(schema) 14 | |> validate_valid(schema) 15 | |> validate_nested(schema) 16 | end 17 | 18 | defp keys_to_atoms(params) do 19 | keys = Map.keys(params) 20 | convert_all_keys(keys, params) 21 | end 22 | 23 | defp convert_all_keys([], _params), do: %{} 24 | defp convert_all_keys([k | tail], params) when is_binary(k) do 25 | if is_map(params[k]) do 26 | Map.put(convert_all_keys(tail, params), String.to_atom(k), keys_to_atoms(params[k])) 27 | else 28 | Map.put(convert_all_keys(tail, params), String.to_atom(k), params[k]) 29 | end 30 | end 31 | defp convert_all_keys([k | tail], params) do 32 | if is_map(params[k]) do 33 | Map.put(convert_all_keys(tail, params), k, keys_to_atoms(params[k])) 34 | else 35 | Map.put(convert_all_keys(tail, params), k, params[k]) 36 | end 37 | end 38 | 39 | defp apply_defaults(params, schema) do 40 | param_keys = Map.keys(params) 41 | 42 | default_schema_keys = 43 | schema 44 | |> Enum.filter(fn({_k, v}) -> 45 | case is_map(v) do 46 | true -> false 47 | false -> Keyword.get(v, :default) !== nil 48 | end 49 | end) 50 | |> Keyword.keys 51 | |> Enum.filter(&(!Enum.member?(param_keys, &1) || params[&1] === nil)) 52 | 53 | add_defaults(params, schema, default_schema_keys) 54 | end 55 | 56 | defp validate_required(params, schema) do 57 | param_keys = Map.keys(params) 58 | 59 | compared_params = 60 | schema 61 | |> Enum.filter(fn({_k, v}) -> 62 | case is_map(v) do 63 | true -> false 64 | false -> Keyword.get(v, :required) === true 65 | end 66 | end) 67 | |> Keyword.keys 68 | |> Enum.drop_while(fn(required_param) -> Enum.member?(param_keys, required_param) end) 69 | 70 | case Enum.empty?(compared_params) do 71 | true -> {:ok, params} 72 | false -> 73 | [first_required_param | _tail] = compared_params 74 | {:error, "#{first_required_param} is required."} 75 | end 76 | end 77 | 78 | defp parse_into_types({:error, message}, _schema), do: {:error, message} 79 | defp parse_into_types({:ok, params}, schema) do 80 | params_keys = Map.keys(params) 81 | 82 | {:ok, check_and_format_types(params, schema, params_keys)} 83 | end 84 | 85 | defp validate_types({:error, message}, _schema), do: {:error, message} 86 | defp validate_types({:ok, params}, schema) do 87 | errors = Enum.reduce params, [], fn {k, v}, error_list -> 88 | type = 89 | case is_map(schema[k]) do 90 | true -> nil 91 | false -> Keyword.get(schema[k] || [], :type) 92 | end 93 | 94 | validate_type({k, v}, schema[k], type) ++ error_list 95 | end 96 | 97 | case Enum.empty?(errors) do 98 | true -> {:ok, params} 99 | false -> 100 | [first_error | _tail] = errors 101 | first_error 102 | end 103 | end 104 | 105 | defp check_and_format_types(params, _schema, []), do: params 106 | defp check_and_format_types(params, schema, [k | tail]) do 107 | expected_type = 108 | case is_map(schema[k]) do 109 | true -> nil 110 | false -> Keyword.get(schema[k] || [], :type) 111 | end 112 | 113 | is_expected? = 114 | case expected_type do 115 | :list -> is_list(params[k]) 116 | :string -> is_binary(params[k]) 117 | :guid -> is_binary(params[k]) 118 | :float -> is_float(params[k]) 119 | :integer -> is_integer(params[k]) 120 | :boolean -> is_boolean(params[k]) 121 | nil -> true 122 | end 123 | 124 | case is_expected? do 125 | true -> Map.put(check_and_format_types(params, schema, tail), k, params[k]) 126 | false -> 127 | parsed_value = try_to_parse(params[k], expected_type) 128 | Map.put(check_and_format_types(params, schema, tail), k, parsed_value) 129 | end 130 | end 131 | 132 | defp try_to_parse(value, :string), do: to_string(value) 133 | defp try_to_parse(value, :guid), do: to_string(value) 134 | defp try_to_parse(value, :float) when is_integer(value), do: String.to_float("#{value}.0") 135 | defp try_to_parse(value, :float) when is_binary(value) do 136 | case Float.parse(value) do 137 | {v, _} -> v 138 | :error -> value 139 | end 140 | end 141 | defp try_to_parse(value, :integer) when is_binary(value) do 142 | case Integer.parse(value) do 143 | {v, _} -> v 144 | :error -> value 145 | end 146 | end 147 | defp try_to_parse(value, :integer) when is_float(value), do: round(value) 148 | defp try_to_parse(value, :list) when is_binary(value), do: String.split(value, ",") 149 | defp try_to_parse(value, :list), do: value 150 | defp try_to_parse("true", :boolean), do: true 151 | defp try_to_parse("false", :boolean), do: false 152 | defp try_to_parse(value, :boolean), do: value 153 | 154 | 155 | defp validate_min({:error, message}, _schema), do: {:error, message} 156 | defp validate_min({:ok, params}, schema) do 157 | minimum_schema_keys = 158 | schema 159 | |> Enum.filter(fn({_k, v}) -> 160 | case is_map(v) do 161 | true -> false 162 | false -> Keyword.get(v, :min) !== nil 163 | end 164 | end) 165 | |> Keyword.keys 166 | 167 | errors = Enum.reduce minimum_schema_keys, [], fn k, error_list -> 168 | minimum = Keyword.get(schema[k], :min) 169 | 170 | case params[k] >= minimum do 171 | true -> [] ++ error_list 172 | false -> [{:error, "#{k} must be greater than or equal to #{minimum}"}] ++ error_list 173 | end 174 | end 175 | 176 | case Enum.empty?(errors) do 177 | true -> {:ok, params} 178 | false -> 179 | [first_error | _tail] = errors 180 | first_error 181 | end 182 | end 183 | 184 | defp validate_max({:error, message}, _schema), do: {:error, message} 185 | defp validate_max({:ok, params}, schema) do 186 | maximum_schema_keys = 187 | schema 188 | |> Enum.filter(fn({_k, v}) -> 189 | case is_map(v) do 190 | true -> false 191 | false -> Keyword.get(v, :max) !== nil 192 | end 193 | end) 194 | |> Keyword.keys 195 | 196 | errors = Enum.reduce maximum_schema_keys, [], fn k, error_list -> 197 | maximum = Keyword.get(schema[k], :max) 198 | 199 | case params[k] <= maximum do 200 | true -> [] ++ error_list 201 | false -> [{:error, "#{k} must be less than or equal to #{maximum}"}] ++ error_list 202 | end 203 | end 204 | 205 | case Enum.empty?(errors) do 206 | true -> {:ok, params} 207 | false -> 208 | [first_error | _tail] = errors 209 | first_error 210 | end 211 | end 212 | 213 | defp validate_valid({:error, message}, _schema), do: {:error, message} 214 | defp validate_valid({:ok, params}, schema) do 215 | valid_keys = 216 | schema 217 | |> Enum.filter(fn({_k, v}) -> 218 | case is_map(v) do 219 | true -> false 220 | false -> Keyword.get(v, :valid) !== nil 221 | end 222 | end) 223 | |> Keyword.keys 224 | 225 | errors = Enum.reduce valid_keys, [], fn k, error_list -> 226 | vals = Keyword.get(schema[k], :valid) 227 | 228 | valid_values = 229 | case is_list(vals) do 230 | true -> vals 231 | false -> [vals] 232 | end 233 | 234 | case Enum.any?(valid_values, &(&1 === params[k])) do 235 | true -> [] ++ error_list 236 | false -> [{:error, "#{k} is not a valid value."}] 237 | end 238 | 239 | end 240 | 241 | case Enum.empty?(errors) do 242 | true -> {:ok, params} 243 | false -> 244 | [first_error | _tail] = errors 245 | first_error 246 | end 247 | end 248 | 249 | defp validate_type({k, _v}, nil, _type), do: [{:error, "#{k} is not a valid parameter"}] 250 | defp validate_type(_param, _schema, nil), do: [] 251 | defp validate_type({k, v}, _schema, :integer) do 252 | case is_integer(v) do 253 | true -> [] 254 | false -> [{:error, "#{k} must be an integer."}] 255 | end 256 | end 257 | 258 | defp validate_type({k, v}, _schema, :float) do 259 | case is_float(v) do 260 | true -> [] 261 | false -> [{:error, "#{k} must be a float."}] 262 | end 263 | end 264 | 265 | defp validate_type({k, v}, _schema, :string) do 266 | case is_binary(v) do 267 | true -> [] 268 | false -> [{:error, "#{k} must be a string."}] 269 | end 270 | end 271 | 272 | defp validate_type({k, v}, _schema, :list) do 273 | case is_list(v) do 274 | true -> [] 275 | false -> [{:error, "#{k} must be a list."}] 276 | end 277 | end 278 | 279 | defp validate_type({k, v}, _schema, :guid) do 280 | case guid_match?(v) do 281 | true -> [] 282 | false -> [{:error, "#{k} must be in valid guid format."}] 283 | end 284 | end 285 | 286 | defp validate_type({k, v}, _schema, :boolean) do 287 | case is_boolean(v) do 288 | true -> [] 289 | false -> [{:error, "#{k} must be a boolean."}] 290 | end 291 | end 292 | 293 | defp add_defaults(params, _schema, []), do: params 294 | defp add_defaults(params, schema, [k | tail]) do 295 | default = Keyword.get(schema[k], :default) 296 | 297 | Map.put(add_defaults(params, schema, tail), k, default) 298 | end 299 | 300 | defp validate_nested({:error, message}, _schema), do: {:error, message} 301 | defp validate_nested({:ok, params}, schema) do 302 | keys_with_maps = 303 | schema 304 | |> Enum.filter(fn({_k, v}) -> is_map(v) end) 305 | |> Keyword.keys 306 | 307 | case each_nested(keys_with_maps, params, schema) do 308 | {:error, message} -> {:error, message} 309 | new_params -> {:ok, new_params} 310 | end 311 | end 312 | 313 | defp each_nested([], params, _schema), do: params 314 | defp each_nested([k | tail], params, schema) do 315 | case is_map(params[k]) do 316 | true -> 317 | case Jeaux.validate(params[k], schema[k]) do 318 | {:ok, new_params} -> Map.put(each_nested(tail, params, schema), k, new_params) 319 | {:error, message} -> {:error, message} 320 | end 321 | 322 | false -> {:error, "expected #{k} to be a map"} 323 | end 324 | end 325 | 326 | defp guid_match?(v) do 327 | Regex.match?(~r/\A[A-F0-9]{8}(?:-?[A-F0-9]{4}){3}-?[A-F0-9]{12}\z/i, v) || 328 | Regex.match?(~r/\A\{[A-F0-9]{8}(?:-?[A-F0-9]{4}){3}-?[A-F0-9]{12}\}\z/i, v) 329 | end 330 | end 331 | --------------------------------------------------------------------------------