Cozy_params - Provides Ecto-like API for casting and validating params

Hey, guys.

Recently, I created a new package called cozy_params for casting and validating params.

It is based on Ecto, and has Ecto-like API.

An example integrating with Phoenix:

defmodule DemoWeb.PageController do
  use DemoWeb, :controller
  import CozyParams

  action_fallback DemoWeb.FallbackController

  defparams :product_search do
    field :name, :string, required: true

    embeds_many :tags do
      field :name, :string, required: true
    end
  end

  def index(conn, params) do
    with {:ok, data} <- product_search(params) do
      # ...
    end
  end
end

defmodule DemoWeb.FallbackController do
  use DemoWeb, :controller

  # ...

  # handle errors for cozy_params
  def call(conn, {:error, params_changeset: %Ecto.Changeset{} = changeset}) do
    messages = CozyParams.get_error_messages(changeset)
    # render messages in HTML, JSON, etc.
  end

  # handle errors for normal changsets from Ecto.
  def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
    # ...
  end

  # ...
end

You can find more details in:

Hope you guys like it.

A development story:

A week ago, I planned to write this package. But, I only know a little about Elixir’s AST and macros. Then, I found a book - Metaprogramming Elixir, and read it last weekend.

The book helps me a lot. And now, I’m an AST newbie in Elixir world. Yeah! :wink:

4 Likes

First :heart: and :star: comes from me. :smiling_imp: Good job! :+1:

There are 3 ideas I would like to share:

  1. I think you should replace params is to params are. I got that you use is for a one (i.e. singular) argument/variable called params (i.e. plural), but you should replace it or change form of all sentences containing params is.

  2. Of course it’s not strictly required, but when you are returning {:error, reason} tuple by default other developers would expect to receive also {:ok, data} tuple instead of just data. Again I understand why you do this, but keep in mind that’s not a common practice and It may become a small gotcha especially for beginner developers.

  3. If you wrote a helper module for Phoenix.Controller it would be worth to mention Phoenix.Controller.action_fallback/1 together with an example of handling {:error, changeset} to render a simple custom error page. Also what if {:error, changeset} would be returned by let’s say update/2 action? I would add something to identify from where said changeset comes, for example: {:error, :params_changeset, changeset} or even better: {:error, params_changeset: changeset}.

3 Likes

I just looked at the controller implementation and I’d suggest considering overwriting action/2 in controllers instead of depending on phoenix implementation details (@actions) to figure out actions of the controller: Phoenix.Controller — Phoenix v1.6.11

3 Likes

@Eiji Thanks for your advice. I have fixed most of issues you mentioned.

  1. fixed.
  2. it will remain as it is:
    • CozyParams uses the common practice like {:ok, _} or {:error, _}.
    • CozyParams.PhoenixController returns the data when params are valid, returns {:error, _} when params are invalid. Although this breaks the common practice, but IMHO, it is more convenient to use.
  3. fixed.
    • I created new sections about error handling for CozyParams and CozyParams.PhoenixController.
    • I changed error tuple from {:error, %Ecto.Changeset{}} to {:error, params_changeset: %Ecto.Changeset{}} (I like this idea :wink: )

If I take up the action/2 function, then users can’t define their own action/2, I think.

Correct me if I am wrong ~

You could given them an API to manually apply the params changes for that case.

1 Like

Another thought, not sure if advisable. You could also define action/2 in a @before_compile hook that checks whether the user has already defined one. If so, you can inject a defoverridable and an action/2 that calls super with the conn and validated params.

2 Likes

@LostKobrakai @zachallaun Thanks for your suggestions.

When I was trying to implement your ideas, I found CozyParams.PhoenixController makes too many assumptions about how users define Phoenix.Controller’s action functions. So, I decided to remove it.

But, you can still use it with Phoenix. And, there’s a section about how to integrating Phoenix in FAQ.md.

Now, cozy_params is a pure package for casting and validating params.

And, it is not necessary to solve the problem #2 mentioned by @Eiji, because that module is gone. :wink:

1 Like

cozy_params 1.0.1 is out.

Main changes:

  • add :pre_cast option to field.
  • more consistent reflection functions.
  • remove useless API, such as from/2.

Because it is >= 1.0.0, in the future, I will maintain a CHANGELOG.md in the repo. I won’t update this topic forwardly.

Lastly, thanks for all your suggestions.

1 Like

Hey thanks for this!

Is there a way to use this to go from a flat params to a more structured data?
To be more clear, we have an app where data comes from outside as a flat map, but we need to convert it to a structure with embeds and associations. Currently we are doing something like this:

defp convert(params) do
  %{
    name: params["name"],
    item: %{
      id: params["id"],
     count: params["count"]
    },
    age: params["age"]
  }
end

But it would be nice if we could do this in a more structured way and have validations in the proces.

Here you go:

Mix.install([:cozy_params])

defmodule Example do
  import CozyParams

  defparams :structured_params do
    field(:age, :integer, required: true)
    field(:name, :string, required: true)

    embeds_one :item do
      field(:count, :integer, required: true)
      field(:id, :integer, required: true)
    end
  end

  @item_fields ~w[count id]

  def sample(params) do
    item_params = Map.take(params, @item_fields)

    params
    |> Map.drop(@item_fields)
    |> Map.put("item", item_params)
    |> structured_params()
  end
end

and here is the example result:

iex> Example.sample(%{"age" => 1, "count" => 1, "id" => 1, "name" => "Name"})
{:ok,
 %Example.CozyParams.StructuredParams{
   age: 1,
   name: "Name",
   item: %Example.CozyParams.StructuredParams.Item{count: 1, id: 1}
 }}

Also how about adding source and sources option to your library @c4710n?

Here are some examples I think are worth to consider:

defmodule Example do
  import CozyParams

  defparams :structured_params do
    field(:age, :integer, required: true)
    # simply a field source
    field(:name, :string, required: true, source: "full_name")

    # with this we could look for a map in "item" param
    # and attach extra params that are in wrong nested position
    # for example:
    # params = %{"item" => %{"id" => 1}, "count" => 1}
    embeds_one :item, source: "item", sources: ~w(count) do
      field(:count, :integer, required: true)
      field(:id, :integer, required: true)
    end

    # also this may be interesting in really big nested structure
    # params = %{"items" => [%{"data" => %{"count" => 1, "id" => 1}}]}
    embeds_one :item2, source: ["items", Access.at(0), "data"] do
      field(:count, :integer, required: true)
      field(:id, :integer, required: true)
    end

    # of course nothing should stop developers from using sources option on
    # embeds_one/embeds_many together with source option on their fields,
    # for example:
    # params = %{"item_count" => 1, "item_id" => 1}
    embeds_one :item3, sources: ~w(item_count item_id) do
      field(:count, :integer, required: true, source: "item_count")
      field(:id, :integer, required: true, source: "item_id")
    end
  end
end

I got idea about source and sources options after I remember that something similar exists in ecto already, see: Optionas section @ Ecto.Schema.field/3.

4 Likes

There’s no support for this case, currently.

@Eiji 's answer is a good example.

1 Like

I like this idea. I have added it to my TODO list.

2 Likes