Handling Parameters in Phoenix

This article focuses on safe and easy handling of parameters in a Phoenix or Phoenix LiveView application. I've found that dealing with parameters can be fraught with headaches, and you might find yourself writing a lot of boilerplate or per-project helper functions to smooth out the process. I've written a library called want that I now use across various projects for this purpose.

To get started, at the following to your mix.exs deps:

def deps do
    [
        {:want, "~> 1.4"}
    ]
end

Now let's dive into some controller code and deal with a fairly typical situation; parsing an ID field that's coming from the user:

defmodule ExampleWeb.PostController do
    use ExampleWeb, :controller

    def show(conn, %{"id" => id}) do
        case Want.integer(id, min: 1) do
            # Great! We've successfully parsed and 
            # validated the ID
            {:ok, id} -> 
                conn
                |> render(:show, post: Posts.get!(id))
            # Whoops! We couldn't parse the ID, let's 
            # render a 400 error
            {:error, _reason} ->
                conn
                |> ErrorHelpers.client_error()
        end
    end
end

This is a pretty simple case. We're using Want.integer/2 to parse a string (or anything that we can transform into an integer), also specifying validation options that ensure the resulting integer is greater than or equal to 1. Want includes a range of casting functions for a variety of types, including boolean, enum, float, string, atom, and sort.

sort is a special case used to cast strings such as title:desc into a tuple in the form {:title, :desc}. It will only parse the fields that you specify, so users cannot use disallowed sort fields.

What if we have a range of parameters that we're parsing, such as parameters used to control the display of a list of items? Enter Want.map/2 and Want/keywords/2:

defmodule ExampleWeb.PostController do
    use ExampleWeb, :controller
    
    @schema %{
        limit: [
            type: :integer, 
            min: 10, 
            max: 100, 
            default: 30
        ],
        offset: [
            type: :integer, 
            min: 0, 
            default: 0
        ],
        sort: [
            type: :sort, 
            fields: [:title, :inserted_at], 
            default: {:inserted_at, :desc}
        ]
    }

    def index(conn, params) do
        case Want.keywords(params, @schema) do
            {:ok, opts} -> 
                conn
                |> render(:index, posts: Posts.list(opts))
            {:error, _reason} ->
                conn
                |> ErrorHelpers.client_error()
        end
    end
end

This is where Want becomes very useful; we simply define the parameters we expect to see in a schema map, including default values and validation options, then we call Want.map/2 or Want.keywords/2, passing the request parameters and schema map. If any parameter is missing or invalid, the configured default will be returned. This ensures that you're passing only valid options to your schema functions.

Want.keywords/2 returns a keyword-list containing the parsed and validated data, Want.map/2 returns a map; you can use whichever suits you. All Want functions have bang versions that raise an error on parse failure. If you specify a default option, they will never raise, returning the default instead.

Hopefully you've found this short article useful and you find that Want helps you to deal with user-supplied parameters easily and safely.