defmodule ExConstructor do @moduledoc ~s""" ExConstructor is an Elixir library that makes it easy to instantiate structs from external data, such as that emitted by a JSON parser. Add `use ExConstructor` after a `defstruct` statement to inject a constructor function into the module. The generated constructor, called `new` by default, handles map-vs-keyword-list, string-vs-atom-keys, and camelCase-vs-under_score input data issues automatically, DRYing up your code and letting you move on to the interesting parts of your program. ## Installation 1. Add ExConstructor to your list of dependencies in `mix.exs`: def deps do [{:exconstructor, "~> #{ExConstructor.Mixfile.project()[:version]}"}] end ## Usage Example: defmodule TestStruct do defstruct field_one: 1, field_two: 2, field_three: 3, field_four: 4, use ExConstructor end TestStruct.new(%{"field_one" => "a", "fieldTwo" => "b", :field_three => "c", :FieldFour => "d"}) # => %TestStruct{field_one: "a", field_two: "b", field_three: "c", field_four: "d"} For advanced usage, see `__using__/1` and `populate_struct/3`. ## Authorship and License ExConstructor is copyright 2016-2021 Appcues, Inc. ExConstructor is released under the [MIT License](https://github.com/appcues/exconstructor/blob/master/LICENSE.txt). """ @type map_or_kwlist :: %{String.t() => any} | %{atom => any} | [{String.t(), any}] | [{atom, any}] defmodule Options do @moduledoc ~S""" Represents the options passed to `populate_struct/3`. Set any value to `false` to disable checking for that kind of key. """ defstruct strings: true, atoms: true, camelcase: true, uppercamelcase: true, underscore: true end @doc ~S""" `use ExConstructor` defines a constructor for the current module's struct. If `name_or_opts` is an atom, it will be used as the constructor name. If `name_or_opts` is a keyword list, `name_or_opts[:name]` will be used as the constructor name. By default, `:new` is used. Additional options in `name_or_opts` are stored in the `@exconstructor_default_options` module attribute. The constructor is implemented in terms of `populate_struct/3`. It accepts a map or keyword list of keys and values `map_or_kwlist`, and an optional `opts` keyword list. Keys of `map_or_kwlist` may be strings or atoms, in camelCase or under_score format. `opts` may contain keys `strings`, `atoms`, `camelcase` and `underscore`. Set these keys false to prevent testing of that key format in `map_or_kwlist`. All default to `true`. For the default name `:new`, the constructor's definition looks like: @spec new(ExConstructor.map_or_kwlist, Keyword.t) :: %__MODULE__{} def new(map_or_kwlist, opts \\ []) do ExConstructor.populate_struct(%__MODULE__{}, map_or_kwlist, Keyword.merge(@exconstructor_default_options, opts)) end defoverridable [new: 1, new: 2] Overriding `new/2` is allowed; the generated function can be called by `super`. Example uses include implementing your own `opts` handling. """ defmacro __using__(name_or_opts \\ :new) do opts = cond do is_atom(name_or_opts) -> [name: name_or_opts] is_list(name_or_opts) -> name_or_opts true -> raise "argument must be atom (constructor name) or keyword list (opts)" end constructor_name = opts[:name] || :new quote do @exconstructor_default_options unquote(opts) @spec unquote(constructor_name)(ExConstructor.map_or_kwlist(), Keyword.t()) :: %__MODULE__{} def unquote(constructor_name)(map_or_kwlist, opts \\ []) do ExConstructor.populate_struct( struct(__MODULE__, []), map_or_kwlist, Keyword.merge(@exconstructor_default_options, opts) ) end defoverridable [{unquote(constructor_name), 1}, {unquote(constructor_name), 2}] end end @doc "Alias for `__using__`, for those who prefer an explicit invocation." defmacro define_constructor(name_or_opts \\ :new) do quote do: ExConstructor.__using__(unquote(name_or_opts)) end @doc ~S""" Returns a copy of `struct` into which the values in `map_or_kwlist` have been applied. Keys of `map_or_kwlist` may be strings or atoms, in camelCase, UpperCamelCase, or under_score format. `opts` may contain keys `strings`, `atoms`, `camelcase`, `uppercamelcase`, and `underscore`. Set these keys false to prevent testing of that key format in `map_or_kwlist`. All default to `true`. """ @spec populate_struct(struct, map_or_kwlist, %Options{} | map_or_kwlist) :: struct def populate_struct(struct, map_or_kwlist, %Options{} = opts) do map = cond do is_map(map_or_kwlist) -> map_or_kwlist is_list(map_or_kwlist) -> Enum.into(map_or_kwlist, %{}) true -> raise "second argument must be a map or keyword list" end keys = case struct do %{__struct__: _t} -> struct |> Map.from_struct() |> Map.keys() _ -> raise "first argument must be a struct" end Enum.reduce(keys, struct, fn atom, acc -> str = to_string(atom) under_str = Macro.underscore(str) up_camel_str = Macro.camelize(str) camel_str = lcfirst(up_camel_str) under_atom = String.to_atom(under_str) camel_atom = String.to_atom(camel_str) value = cond do Map.has_key?(map, str) and opts.strings -> Map.get(map, str) Map.has_key?(map, atom) and opts.atoms -> Map.get(map, atom) Map.has_key?(map, under_str) and opts.strings and opts.underscore -> Map.get(map, under_str) Map.has_key?(map, under_atom) and opts.atoms and opts.underscore -> Map.get(map, under_atom) Map.has_key?(map, up_camel_str) and opts.strings and opts.uppercamelcase -> Map.get(map, up_camel_str) Map.has_key?(map, camel_str) and opts.strings and opts.camelcase -> Map.get(map, camel_str) Map.has_key?(map, camel_atom) and opts.atoms and opts.camelcase -> Map.get(map, camel_atom) true -> Map.get(struct, atom) end Map.put(acc, atom, value) end) end def populate_struct(struct, map_or_kwlist, opts) do opts_struct = try do populate_struct(%Options{}, opts, %Options{}) rescue ## prevent confusing error message ex in RuntimeError -> case ex.message do "second argument" <> _ -> raise "third argument must be a map or keyword list" _ -> raise ex end end populate_struct(struct, map_or_kwlist, opts_struct) end @doc ~S""" Returns a copy of `struct` into which the values in `map_or_kwlist` have been applied. Keys of `map_or_kwlist` may be strings or atoms, in camelCase or under_score format. """ @spec populate_struct(struct, map_or_kwlist) :: struct def populate_struct(struct, map_or_kwlist) do populate_struct(struct, map_or_kwlist, %Options{}) end ## Returns `str` with its first character lowercased. @spec lcfirst(String.t()) :: String.t() defp lcfirst(str) do first = String.slice(str, 0..0) |> String.downcase() first <> String.slice(str, 1..-1) end end