wrren

wrren

I wrote this library several years ago, but I’ve recently had a use case for it that’s caused me to make several updates that I think would also make it quite useful for others. Initially, I wanted a library that let me convert between types in Erlang and Elixir in a logical and consistent way. Over time, I’ve extended it to try and solve the general problem of coercing complex data from various sources into well-defined structures. On a basic level, want lets you convert between scalar types.

# String to integer
{:ok, 1} = Want.integer("1")
# String to integer, raise on failure
1 = Want.integer!("1")
# String to float
{:ok, 1.0} = Want.float("1")
# String to float, raise on failure
1.0 = Want.float!("1")
# Integer to string
{:ok, "1"} = Want.string(1)
# Integer to string, raise on failure
"1" = Want.string!(1)
# String to boolean
{:ok, true} = Want.boolean("true")
{:ok, false} = Want.boolean("FALSE")
# String to boolean, raise on failure
true = Want.boolean!("true")

However where want really shines is in defining structures and casting incoming data into them. This is similar to what Ecto.Schema can accomplish, but in a more generic way and with more options that let you deal with dirty data.

First you can define a schema for conversion using a map, and then convert incoming data into a map or keyword list like so:

@schema %{
  hello:    [type: :string, from: "world"],
  foo:       [type: :integer, default: 0]
}

{:ok, %{hello: "goodbye", foo: 0}} = Want.map(%{"world" => "goodbye"}, @schema) 

In this simple example you can see how a schema lets you define source fields, types, defaults, etc. We can make this more complex like so:

@schema %{
  hello:     [type: :string, from: "world", transform: %String.upcase/1],
  foo:        [type: :integer, default: 0, from: {"a", "b", "c"}]
}

{:ok, %{hello: "GOODBYE", foo: 10}} = Want.map(%{"world" => "goodbye", "a" => %{"b" => %{"c" => 10}}}, @schema) 

Here you can see field transformations in action, which are applied after a field has been cast, as well as sourcing data from nested fields.

Finally we have shape/1, which is a macro that lets us define a schema and struct at the same time, exposing functions for casting from data or lists of data.

defmodule MyShape do
  use Want.Shape

  shape transform: &__MODULE__.transform/1 do
    field :is_valid,       :boolean,   default: false
    field :count,          :integer,   default: 0
    field :from,            :string,    from: "FromField"
    field :multi_from,  :integer,   from: ["a", {"b", "c", "d"}], default: 0
  end

  def transform(%MyShape{count: c} = s),
    do: %{s | count: c * 2}
end

{:ok, %MyModule{is_valid: true, count: 20, from: "Foo", multi_from: 10}} = MyShape.cast(%{
    "is_valid"  => "true",
    "count"     => "10",
    "from"      => "Foo",
    "b"         => %{
        "c" => %{
            "d" => 10
        }
    }
})

As with map schemas, you can define types and options. shape also lets you specify a transformation function that’s applied to the entire result after casting. use Want.Shape exposes the cast/1, cast!/1, cast_all/1 and cast_all!/1 functions as part of your module.

Want on hex.pm
Want on github:
https://github.com/wrren/want.erl

Showing Posts 1 to 8

Eiji

Eiji

What I generally advise is to use common naming that you can find in Elixir, ecto or phoenix. In this case I recommend to use: source instead of from as it’s already well known in ecto, see: :source option in: Options | Ecto.Schema.field/3. :books:

I like the idea of flexibility between passing schema map and using DSL. :+1:

mayel

mayel

Thanks for sharing, this looks handy!

Is there a way to define custom types to to extend the type parsing rules? In the meantime, here’s a quick PR, as I can see this being a quick win for parsing ENV variables for config: Comparing wrren:master...mayel:patch-1 · wrren/want.erl · GitHub

Eiji

Eiji

You forgot to create a PR :slight_smile:

For CLI I would also suggest:

  1. y (short for yes)
  2. t (short for true)
  3. n (short for no)
  4. f (short for false)

I use them more often than 0 and 1.

cond do
  String.downcase(value) in ~w"true t yes y 1" -> {:ok, true}
  String.downcase(value) in ~w"false f no n 0" -> {:ok, false}
  true -> {:error, "Failed to convert value #{inspect(value)} to boolean."}
end
wrren

wrren OP

Thanks! I’ve merged your patch. Regarding custom types, that’s a cool idea. It should be reasonably trivial using a behaviour and some checks. I’ll add that feature today.

wrren

wrren OP

Custom type support has been added. You just need to implement the Want.Type behaviour and it will work. See the updated README for more details.

code-shoily

code-shoily

Lol looking at the title, I thought you “wanted” a library for this and this was a wish list :slight_smile:

It looks good.

mayel

mayel

Thanks for merging and adding custom types so quickly :slight_smile:

Here’s a quick module I put together for parsing env vars into config, it ended up being more of a wrapper around Want than a custom type, though I guess it could be both if we had a function like Want.cast(input, CustomType, opts). Posting it here for feedback and if it looks good to you I’ll submit a couple more PRs (mentioned in the code by TODOs) to simplify things.

defmodule EnvConfig do
  @moduledoc """
  A Want type that reads environment variables and returns them as keyword lists or map(s).

  ## Features

    - Collects environment variables with a specified prefix.
    - Allows key transformation via `:transform_keys`.
    - Supports type casting via `:want_values` using the `Want` library.
    - Supports both single (e.g. `MYAPP_DB_HOST`) and a list of configuration groups (e.g. `MYAPP_DB_1_HOST`, `MYAPP_DB_2_HOST`, etc).
    - Returns keyword lists if all keys are atoms, otherwise returns maps.

  """
  use Want.Type

  @doc """
  Casts environment variables into keyword list(s) or map(s).

  ## Options

    - `prefix` (required): Prefix for environment variable matching.
    - `transform_keys` (optional): Function to transform keys (e.g., `&String.to_existing_atom/1`).
    - `want_values` (optional): Map of key type casts with optional defaults.
    - `want_unknown_keys` (optional): Whether to also include unknown keys when using `want_values`.
    - `indexed_list` (optional): Looks for an indexed list of env vars. Default: `false`.
    - `max_index` (optional): Maximum index for indexed configs. Default: `1000`.
    - `max_empty_streak` (optional): Stops after this many consecutive missing indices. Default: `10`.

  ## Examples

  ### Basic usage (usage as a `Want` custom type)

      iex> System.put_env("TESTA_DB_HOST", "localhost")
      iex> Want.cast(System.get_env(), EnvConfig, prefix: "TESTA_DB") # FIXME: Want doesn't currently have a way to cast with a custom type at the top-level, only for data within a map or keyword list
      {:ok, %{"host"=> "localhost"}}

  ### Basic usage with prefix only (direct usage)

      iex> System.put_env("TESTA_DB_HOST", "localhost")
      iex> EnvConfig.parse(System.get_env(), prefix: "TESTA_DB") 
      %{"host"=> "localhost"}

  ### Basic usage with prefix only (direct usage, uses env from `System.get_env()` by default)

      iex> System.put_env("TESTA_DB_HOST", "localhost")
      iex> EnvConfig.parse(prefix: "TESTA_DB") 
      %{"host"=> "localhost"}

  ### With key transformation

      iex> System.put_env("TESTB_DB_HOST", "localhost")
      iex> System.put_env("TESTB_DB_PORT", "5432")
      iex> EnvConfig.parse(
      ...>   prefix: "TESTB_DB",
      ...>   transform_keys: &String.to_existing_atom/1,
      ...> ) 
      ...> |> Map.new() # just to make the test assertion easier
      %{host: "localhost", port: "5432"}

  ### With type casting for specific keys

      iex> System.put_env("TESTC_DB_PORT", "5432")
      iex> System.put_env("TESTC_DB_MAX_CONNECTIONS", "100")
      iex> System.put_env("TESTC_DB_SSL", "true")
      iex> EnvConfig.parse(
      ...>   prefix: "TESTC_DB",
      ...>   want_values: %{
      ...>     port: :integer,
      ...>     max_connections: {:integer, default: 3},
      ...>     ssl: :boolean
      ...>   }
      ...> ) 
      ...> |> Map.new() # just to make the test assertion easier
      %{ssl: true, max_connections: 100, port: 5432}

  ### With type casting for only some keys, including unknown keys as well (returns a map with mixed keys)

      iex> System.put_env("TESTU_DB_PORT", "5432")
      iex> System.put_env("TESTU_DB_MAX_CONNECTIONS", "100")
      iex> %{"max_connections"=> "100", port: 5432} = EnvConfig.parse(
      ...>   prefix: "TESTU_DB",
      ...>   want_unknown_keys: true,
      ...>   want_values: %{
      ...>     port: :integer
      ...>   }
      ...> ) 

  ### With both transformation and type casting

      iex> System.put_env("TESTD_DB_HOST_", "localhost")
      iex> EnvConfig.parse(
      ...>   prefix: "TESTD_DB",
      ...>   transform_keys: &String.trim(&1, "_"),
      ...>   want_values: %{
      ...>     host: :string
      ...>   }
      ...> )
      [host: "localhost"]

  ### Indexed list of configs

      iex> System.put_env("TESTE_DB_0_HOST", "localhost")
      iex> System.put_env("TESTE_DB_1_HOST", "remote")
      iex> EnvConfig.parse(
      ...>   prefix: "TESTE_DB",
      ...>   want_values: %{
      ...>     host: :string
      ...>   },
      ...>   indexed_list: true
      ...> )
      [[host: "localhost"], [host: "remote"]]
  """
  @impl true
  def cast(input, opts) do
    case parse(input, opts) do
      {:ok, data} -> {:ok, data}
      {:error, e} -> {:error, e}
      data -> {:ok, data}
    end
  end

  def parse(input \\ nil, opts) do
    prefix = Keyword.fetch!(opts, :prefix)
    indexed_list = Keyword.get(opts, :indexed_list, false)

    parse_configs(input || System.get_env(), indexed_list, opts)
  end

  defp parse_configs(env, false, opts) do
    with {:ok, config} <- parse_env_vars(env, opts) do
      config
    end
  end

  defp parse_configs(env, true = _indexed, opts) do
    prefix = Keyword.fetch!(opts, :prefix)
    max_index = Keyword.get(opts, :max_index, 1000)
    max_empty_streak = Keyword.get(opts, :max_empty_streak, 10)

    Stream.iterate(0, &(&1 + 1))
    |> Stream.take(max_index + 1)
    |> Stream.transform({[], 0}, fn index, {acc, empty_count} ->
      config = parse_env_vars(index, env, opts)

      case config do
        nil ->
          if empty_count >= max_empty_streak - 1 do
            {:halt, {acc, empty_count + 1}}
          else
            {[], {acc, empty_count + 1}}
          end

        {:error, e} ->
          raise RuntimeError, reason: e

        {:ok, config} ->
          {[config], {acc ++ [config], 0}}
      end
    end)
    |> Enum.to_list()
  end

  defp parse_env_vars(index \\ nil, env, opts) do
    prefix = Keyword.fetch!(opts, :prefix)
    want_unknown_keys = Keyword.get(opts, :want_unknown_keys, false)
    want_values = Keyword.get(opts, :want_values)
    transform_keys = Keyword.get(opts, :transform_keys, & &1)

    # Build the pattern based on whether we're reading an indexed list of vars
    prefix_pattern =
      if index do
        "^#{prefix}_#{index}_(.+)$"
      else
        "^#{prefix}_(.+)$"
      end

    # Get matching environment variables
    matching_vars = get_matching_vars(env, prefix_pattern)

    if matching_vars == %{} do
      nil
    else
      matching_vars
      |> Enum.map(fn {key, value} ->
        transformed_key =
          key
          |> transform_keys.()

        {transformed_key, value}
      end)
      |> maybe_want(want_unknown_keys, want_values)
    end
  end

  defp get_matching_vars(env, prefix_pattern) do
    env
    |> Enum.filter(fn {key, _value} ->
      Regex.match?(~r/#{prefix_pattern}/i, key)
    end)
    |> Enum.map(fn {key, value} ->
      [_full, key_suffix] = Regex.run(~r/#{prefix_pattern}/i, key)

      {String.downcase(key_suffix), value}
    end)
    |> Enum.into(%{})
  end

  def maybe_want(input, _, nil), do: Map.new(input)

  def maybe_want(input, true, want_values) do
    with {:ok, wanted_map} <- Want.map(input, prepare_want_map_schema(want_values)) do
      # TODO: submit PR to Want adding an option to include unknown keys instead
      {:ok, Enum.into(wanted_map, input) |> Map.new()}
    end
  end

  def maybe_want(input, _false, want_values) do
    Want.keywords(input, prepare_want_map_schema(want_values))
  end

  # TODO: submit PR to Want adding an option to include the type as an atom in schemas if no other options are needed
  defp prepare_want_map_schema(%{} = want) do
    want
    |> Enum.map(fn {k, v} ->
      {
        k,
        prepare_want_map_schema(v)
      }
    end)
    |> Enum.into(%{})
  end

  defp prepare_want_map_schema(nil), do: [key: :string]
  defp prepare_want_map_schema(type) when is_atom(type), do: [type: type]

  defp prepare_want_map_schema({type, opts}) when is_list(opts) and is_atom(type),
    do: Keyword.put(opts, :type, type)

  defp prepare_want_map_schema(v), do: v

end
— All posts loaded —

Where Next? Top

Trending in Announcing Top

wojtekmach
Hey everyone! Req is an HTTP client for Elixir that I’ve been working on for quite some time. There is already a lot of HTTP clients out...
New
handnot2
Samly can be used to enable SAML 2.0 Single Sign On in a Plug/Phoenix application. This library uses Erlang esaml to provide plug enabl...
New
woylie
Flop is an Elixir library that applies filtering, ordering and pagination parameters to your Ecto queries. offset-based pagination with...
New
restlessronin
The repo is at GitHub - cyberchitta/openai_ex: Community maintained Elixir library for OpenAI API · GitHub. Docs are at OpenaiEx User Gu...
152 11030 135
New
garrison
Hobbes is a low-level distributed database for the Elixir programming language. Hobbes provides a simple, safe, and scalable storage lay...
New
fuelen
Hi all! I want to present a small library which provides a mix task for generating an Entity-Relationship Diagram for Ecto schemas. You...
New
woylie
Phoenix components for pagination, sortable tables and filter forms with Flop and (optionally) Ecto. pagination cursor pagination sorta...
New

Other Trending Topics Top

mudasobwa
I am happy to introduce the very α version of the new programming language compiled to BEAM. Welcome Cure. It has literally three kille...
New
mudasobwa
I am seeing a lot of aplications of Argumentum ad Vericundiam in software discussions. They do link some piece of writing and point us to...
New
bartblast
Hey folks, I just published a post about Hologram’s funding and where the project goes next - the short version: Curiosum as Main Spons...
New
sorenone
Today we’re releasing Oban for Python. Not an Oban client in Python. Not a pythonx wrapper embedded in Elixir. Nope, it’s a fully operati...
New
Herve37
We’re evaluating API mocking tools for OpenAPI-based projects and would love to hear what other teams are using. We’re particularly inte...
New
sergio
It’s not that it’s vocabulary is too advanced. It’s something worse. I get lost trying to follow even a paragraph written by Claude. It’...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews