├── .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 |
--------------------------------------------------------------------------------