├── .gitignore ├── .tool-versions ├── .travis.yml ├── LICENSE ├── README.md ├── config └── config.exs ├── lib └── better_params.ex ├── mix.exs ├── mix.lock └── test ├── better_params_test.exs └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # 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 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.6.5 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.3.0 4 | - 1.4.0 5 | otp_release: 6 | - 18.0 7 | - 19.0 8 | sudo: false 9 | script: 10 | - mix test 11 | after_script: 12 | - MIX_ENV=docs mix deps.get 13 | - MIX_ENV=docs mix inch.report 14 | 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Sheharyar Naseer - http://sheharyar.me/ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [BetterParams][docs] 2 | ==================== 3 | 4 | [![Build Status][shield-travis]][travis-ci] 5 | [![Coverage Status][shield-inch]][docs] 6 | [![Version][shield-version]][hexpm] 7 | [![License][shield-license]][hexpm] 8 | 9 | > Cleaner request parameters in Elixir web applications 🙌 10 | 11 | `BetterParams` is a simple Elixir [Plug][plug] that allows passed 12 | request parameters to be called as `atoms` instead of Strings. The 13 | sole purpose of this Plug is to pattern match on maps with atom keys 14 | instead of string keys in routers/controllers to calm my OCD down. 15 | 16 |
17 | 18 | 19 | 20 | 21 | ## Usage 22 | 23 | Once installed, it lets you pattern match request parameters like 24 | `%{id: id}` instead of `%{"id" => id}` in Phoenix applications: 25 | 26 | ```elixir 27 | # web/controllers/some_controller.ex 28 | 29 | def show(conn, %{id: id}) do 30 | # do something 31 | end 32 | 33 | def create(conn, %{id: id, post: %{title: title, body: body}}) do 34 | # do something 35 | end 36 | ``` 37 | 38 | 39 | #### Notes 40 | 41 | - Implementation uses [`String.to_existing_atom`][string-atom] to prevent 42 | against [DoS attacks][gh-issue-ddos], so it only converts those params 43 | to atoms that you use in your application. 44 | - You can continue to use String keys without breaking your existing 45 | matches if you want. All request parameters are available for both 46 | `String` and `Atom` keys (that have already been defined in the 47 | application). 48 | - This doesn't pollute your Request Logs with duplicate params. 49 | - For other `Plug.Router` based applications, you can also access request 50 | params similarly by calling them like `conn.params[:id]` or 51 | `conn.params.post.title`. 52 | 53 |
54 | 55 | 56 | 57 | 58 | ## Installation 59 | 60 | Add `better_params` to your project dependencies in `mix.exs`: 61 | 62 | ```elixir 63 | def deps do 64 | [{:better_params, "~> 0.5.0"}] 65 | end 66 | ``` 67 | 68 | 69 | 70 | ### Phoenix Framework 71 | 72 | For Phoenix applications, call the plug at the end of the `controller` 73 | method in `web/web.ex` (inside the `quote` block): 74 | 75 | ```elixir 76 | # web/web.ex 77 | 78 | def controller do 79 | quote do 80 | use Phoenix.Controller 81 | 82 | # Other stuff... 83 | 84 | plug BetterParams 85 | end 86 | end 87 | ``` 88 | 89 | Alternatively, you can also call it your Router Pipelines or in 90 | individual controllers directly. 91 | 92 | 93 | 94 | ### Other Plug.Router Apps 95 | 96 | For other applications using `Plug.Router`, call the Plug anytime after 97 | calling the `:match` and `:dispatch` plugs: 98 | 99 | ```elixir 100 | defmodule MyApp.Router do 101 | use Plug.Router 102 | 103 | plug :match 104 | plug :dispatch 105 | plug BetterParams 106 | 107 | # Rest of the router... 108 | end 109 | 110 | ``` 111 | 112 | 113 | 114 | ### Removing String Keys Entirely 115 | 116 | If your use case calls for a params object with _only_ `Atom` keys, you 117 | may pass the option `drop_string_keys` to the plug. Much as it says on 118 | the can, this will replace the `String`-type keys altogether, rather 119 | than preserving them alongside the `Atom` keys. 120 | 121 | ```elixir 122 | plug BetterParams, drop_string_keys: true 123 | ``` 124 | 125 |
126 | 127 | 128 | 129 | 130 | ## Roadmap 131 | 132 | - [x] Write Tests 133 | - [x] Write Documentation 134 | - [x] Symbolize the collective `params` map 135 | - [x] Option to remove string keys entirely 136 | - [ ] Symbolize [individual parameter maps][plug-params] (if the need arises) 137 | - [ ] `path_params` 138 | - [ ] `body_params` 139 | - [ ] `query_params` 140 | 141 |
142 | 143 | 144 | 145 | 146 | ## Contributing 147 | 148 | - [Fork][github-fork], Enhance, Send PR 149 | - Lock issues with any bugs or feature requests 150 | - Implement something from Roadmap 151 | - Spread the word :heart: 152 | 153 |
154 | 155 | 156 | 157 | 158 | ## License 159 | 160 | This package is available as open source under the terms of the [MIT License][license]. 161 | 162 |
163 | 164 | 165 | 166 | 167 | [shield-version]: https://img.shields.io/hexpm/v/better_params.svg 168 | [shield-license]: https://img.shields.io/hexpm/l/better_params.svg 169 | [shield-downloads]: https://img.shields.io/hexpm/dt/better_params.svg 170 | [shield-travis]: https://img.shields.io/travis/sheharyarn/better_params/master.svg 171 | [shield-inch]: https://inch-ci.org/github/sheharyarn/better_params.svg?branch=master 172 | 173 | [travis-ci]: https://travis-ci.org/sheharyarn/better_params 174 | [inch-ci]: https://inch-ci.org/github/sheharyarn/better_params 175 | 176 | [license]: https://opensource.org/licenses/MIT 177 | [hexpm]: https://hex.pm/packages/better_params 178 | [plug]: https://github.com/elixir-lang/plug 179 | [plug-params]: https://hexdocs.pm/plug/Plug.Conn.html#module-fetchable-fields 180 | [string-atom]: https://hexdocs.pm/elixir/String.html#to_existing_atom/1 181 | 182 | [docs]: https://hexdocs.pm/better_params 183 | 184 | [github-fork]: https://github.com/sheharyarn/better_params/fork 185 | [gh-issue-ddos]: https://github.com/sheharyarn/better_params/issues/1 186 | -------------------------------------------------------------------------------- /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 :better_params, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:better_params, :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 | -------------------------------------------------------------------------------- /lib/better_params.ex: -------------------------------------------------------------------------------- 1 | defmodule BetterParams do 2 | @behaviour Plug 3 | 4 | @moduledoc """ 5 | Implementation of the `BetterParams` plug and its core logic. 6 | 7 | See the [`README`](https://github.com/sheharyarn/better_params) for 8 | installation and usage instructions. 9 | """ 10 | 11 | 12 | 13 | 14 | @doc """ 15 | Initializes the Plug. 16 | """ 17 | @impl Plug 18 | def init(opts) do 19 | opts 20 | end 21 | 22 | 23 | 24 | 25 | @doc """ 26 | Implementation of the Plug 27 | 28 | This implements the `call` callback of the Plug. It calls the 29 | `symbolize_merge/2` method on the `Plug.Conn` params map, so 30 | they are available both with String and Atom keys. 31 | """ 32 | @impl Plug 33 | def call(%{params: params} = conn, opts) do 34 | %{ conn | params: symbolize_merge(params, opts) } 35 | end 36 | 37 | 38 | 39 | 40 | @doc """ 41 | Converts String keys of a Map to Atoms 42 | 43 | Takes a Map with String keys and an optional keyword list 44 | of options. Returns a new map with all values accessible 45 | by both String and Atom keys. If the map is nested, it 46 | symbolizes all sub-maps as well. 47 | 48 | The only option supported at this time is `drop_string_keys` 49 | which defaults to `false`. When set `true`, it will 50 | return a new map with only Atom version of the keys. 51 | 52 | ## Example 53 | 54 | ``` 55 | map = %{"a" => 1, "b" => %{"c" => 2, "d" => 3}} 56 | 57 | mixed_map = BetterParams.symbolize_merge(map) 58 | # => %{:a => 1, :b => %{c: 2, d: 3}, "a" => 1, "b" => %{"c" => 2, "d" => 3}} 59 | 60 | atom_map = BetterParams.symbolize_merge(map, drop_string_keys: true) 61 | # => %{a: 1, b: %{c: 2, d: 3}} 62 | 63 | mixed_map[:a] # => 1 64 | mixed_map[:b][:c] # => 2 65 | mixed_map.b.d # => 3 66 | ``` 67 | """ 68 | @spec symbolize_merge(map :: map, opts :: Keyword.t) :: map 69 | def symbolize_merge(map, opts \\ []) when is_map(map) do 70 | atom_map = 71 | map 72 | |> Map.delete(:__struct__) 73 | |> symbolize_keys 74 | 75 | if Keyword.get(opts, :drop_string_keys, false) do 76 | atom_map 77 | else 78 | Map.merge(map, atom_map) 79 | end 80 | end 81 | 82 | 83 | 84 | ## Private Methods 85 | 86 | 87 | defp symbolize_keys(%{__struct__: _module} = struct) do 88 | struct 89 | end 90 | 91 | defp symbolize_keys(map) when is_map(map) do 92 | Enum.reduce(map, %{}, fn {k, v}, m -> 93 | map_put(m, k, symbolize_keys(v)) 94 | end) 95 | end 96 | 97 | defp symbolize_keys(list) when is_list(list) do 98 | Enum.map(list, &symbolize_keys/1) 99 | end 100 | 101 | defp symbolize_keys(term), do: term 102 | 103 | 104 | 105 | defp map_put(map, k, v) when is_map(map) do 106 | cond do 107 | is_binary(k) -> Map.put(map, String.to_existing_atom(k), v) 108 | true -> Map.put(map, k, v) 109 | end 110 | rescue 111 | ArgumentError -> map 112 | end 113 | 114 | end 115 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule BetterParams.Mixfile do 2 | use Mix.Project 3 | 4 | @app :better_params 5 | @name "BetterParams" 6 | @version "0.5.0" 7 | @github "https://github.com/sheharyarn/#{@app}" 8 | 9 | 10 | # NOTE: 11 | # To publish package or update docs, use the `docs` 12 | # mix environment to not include support modules 13 | # that are normally included in the `dev` environment 14 | # 15 | # MIX_ENV=docs hex.publish 16 | # 17 | 18 | 19 | def project do 20 | [ 21 | # Project 22 | app: @app, 23 | version: @version, 24 | elixir: "~> 1.3", 25 | description: description(), 26 | package: package(), 27 | deps: deps(), 28 | 29 | # ExDoc 30 | name: @name, 31 | source_url: @github, 32 | homepage_url: @github, 33 | docs: [ 34 | main: @name, 35 | canonical: "https://hexdocs.pm/#{@app}", 36 | extras: ["README.md"] 37 | ] 38 | ] 39 | end 40 | 41 | 42 | def application do 43 | [applications: []] 44 | end 45 | 46 | 47 | defp deps do 48 | [ 49 | {:plug, ">= 1.0.0" }, 50 | {:ex_doc, ">= 0.0.0", only: :docs }, 51 | {:inch_ex, ">= 0.0.0", only: :docs }, 52 | ] 53 | end 54 | 55 | 56 | defp description do 57 | "Cleaner Plug params for Elixir web applications 🙌" 58 | end 59 | 60 | 61 | defp package do 62 | [ 63 | name: @app, 64 | maintainers: ["Sheharyar Naseer"], 65 | licenses: ["MIT"], 66 | files: ~w(mix.exs lib README.md), 67 | links: %{"Github" => @github} 68 | ] 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"earmark": {:hex, :earmark, "1.2.2", "f718159d6b65068e8daeef709ccddae5f7fdc770707d82e7d126f584cd925b74", [:mix], [], "hexpm"}, 2 | "ex_doc": {:hex, :ex_doc, "0.16.1", "b4b8a23602b4ce0e9a5a960a81260d1f7b29635b9652c67e95b0c2f7ccee5e81", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, 3 | "ex_utils": {:hex, :ex_utils, "0.1.7", "2c133e0bcdc49a858cf8dacf893308ebc05bc5fba501dc3d2935e65365ec0bf3", [:mix], [], "hexpm"}, 4 | "inch_ex": {:hex, :inch_ex, "0.5.6", "418357418a553baa6d04eccd1b44171936817db61f4c0840112b420b8e378e67", [:mix], [{:poison, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "mime": {:hex, :mime, "1.1.0", "01c1d6f4083d8aa5c7b8c246ade95139620ef8effb009edde934e0ec3b28090a", [:mix], [], "hexpm"}, 6 | "plug": {:hex, :plug, "1.3.5", "7503bfcd7091df2a9761ef8cecea666d1f2cc454cbbaf0afa0b6e259203b7031", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}} 8 | -------------------------------------------------------------------------------- /test/better_params_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BetterParams.Tests do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | alias BetterParams.Tests.Meta.Router 6 | alias BetterParams.Tests.Meta.AtomRouter 7 | alias BetterParams.Tests.Meta.Helpers 8 | 9 | 10 | 11 | # Module Methods 12 | # -------------- 13 | 14 | 15 | describe "#symbolize_merge" do 16 | test "it symbolizes a map and merges it with itself" do 17 | m_before = %{"a" => 1, "b" => 2, "c" => "3"} 18 | m_after = Map.merge(m_before, %{a: 1, b: 2, c: "3"}) 19 | 20 | assert BetterParams.symbolize_merge(m_before, []) == m_after 21 | end 22 | 23 | 24 | test "it deep symbolizes maps" do 25 | m_before = %{"a" => 1, "b" => %{"c" => 2, "d" => "3"}} 26 | m_after = Map.merge(m_before, %{a: 1, b: %{c: 2, d: "3"}}) 27 | 28 | assert BetterParams.symbolize_merge(m_before, []) == m_after 29 | end 30 | 31 | 32 | test "it deep symbolizes lists of maps" do 33 | m_before = %{"a" => 1, "b" => %{"c" => 2, "d" => "3", "e" => [%{"f" => 4}, %{"g" => "5"}]}} 34 | m_after = Map.merge(m_before, %{a: 1, b: %{c: 2, d: "3", e: [%{f: 4}, %{g: "5"}]}}) 35 | 36 | assert BetterParams.symbolize_merge(m_before, []) == m_after 37 | end 38 | 39 | 40 | test "it leaves non-map terms untouched" do 41 | m_before = %{"a" => [1, 2, %{"b" => 3}]} 42 | m_after = Map.merge(m_before, %{a: [1, 2, %{b: 3}]}) 43 | 44 | assert BetterParams.symbolize_merge(m_before, []) == m_after 45 | end 46 | 47 | 48 | test "it ignores structs" do 49 | params = %{ file: Helpers.build_upload("some/file") } 50 | 51 | assert BetterParams.symbolize_merge(params, []) == params 52 | end 53 | end 54 | 55 | 56 | 57 | 58 | # Plug Behaviour 59 | # -------------- 60 | 61 | 62 | describe "plug" do 63 | @opts Router.init([]) # Router Initialization 64 | @atom_opts AtomRouter.init([]) # Router Initialization 65 | 66 | 67 | test "params map has both atom and string keys" do 68 | params = 69 | :get 70 | |> conn("/test/1/2/3") # New Connection 71 | |> Router.call(@opts) # Invoke Router 72 | |> Map.get(:params) # Fetch Params 73 | 74 | # Assert param values 75 | assert params[:a] == "1" 76 | assert params[:b] == "2" 77 | assert params[:c] == "3" 78 | 79 | assert params["a"] == "1" 80 | assert params["b"] == "2" 81 | assert params["c"] == "3" 82 | end 83 | 84 | test "params map has only atom keys when drop_string_keys is true" do 85 | params = 86 | :get 87 | |> conn("/test/1/2/3") # New Connection 88 | |> AtomRouter.call(@atom_opts) # Invoke Router 89 | |> Map.get(:params) # Fetch Params 90 | 91 | # Assert param values 92 | assert params[:a] == "1" 93 | assert params[:b] == "2" 94 | assert params[:c] == "3" 95 | 96 | refute params["a"] 97 | refute params["b"] 98 | refute params["c"] 99 | end 100 | 101 | 102 | test "works with file uploads" do 103 | upload = Helpers.build_upload("another/file.png") 104 | 105 | params = 106 | :post 107 | |> conn("/test/upload/something", %{data: upload}) 108 | |> Router.call(@opts) 109 | |> Map.get(:params) 110 | 111 | 112 | # Assert normal params 113 | assert params[:id] == "something" 114 | assert params["id"] == "something" 115 | 116 | # Assert file upload 117 | assert params[:data] == upload 118 | assert params["data"] == upload 119 | end 120 | end 121 | 122 | end 123 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | 2 | # Test-Specific Modules 3 | defmodule BetterParams.Tests.Meta do 4 | 5 | # Helper Methods 6 | defmodule Helpers do 7 | def build_upload(path) do 8 | %Plug.Upload{path: path, filename: Path.basename(path)} 9 | end 10 | end 11 | 12 | 13 | # Minimal Router to test our Plug against 14 | defmodule Router do 15 | use Plug.Router 16 | 17 | plug :match 18 | plug :dispatch 19 | plug BetterParams 20 | 21 | get "/test/:a/:b/:c" do 22 | send_resp(conn, 200, "ok") 23 | end 24 | 25 | post "/test/upload/:id" do 26 | send_resp(conn, 200, "ok") 27 | end 28 | end 29 | 30 | # String-dropping Router to test our Plug against 31 | defmodule AtomRouter do 32 | use Plug.Router 33 | 34 | plug :match 35 | plug :dispatch 36 | plug BetterParams, drop_string_keys: true 37 | 38 | get "/test/:a/:b/:c" do 39 | send_resp(conn, 200, "ok") 40 | end 41 | 42 | post "/test/upload/:id" do 43 | send_resp(conn, 200, "ok") 44 | end 45 | end 46 | end 47 | 48 | 49 | 50 | # Run our tests 51 | ExUnit.start() 52 | --------------------------------------------------------------------------------