# Entitiex
Inspired by [`grape-entity`](https://github.com/ruby-grape/grape-entity) gem.
<!-- EXDOC -->
Entitiex is an Elixir presenter library used to transform data structures. This is useful when the desired representation doesn't match the schema defined within the domain model. I'd say it's a kind of `Grape::Entity` ported from the Ruby world.
## Installation
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `entitiex` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:entitiex, "~> 0.0.1"}
]
end
```
## Usage
### Defining Entities
Entities should use `Entitiex.Entity`, it gives convenient DSL to define a scheme of an entity.
```elixir
defmodule UserEntity do
use Entitiex.Entity
end
```
#### Define Exposures
Define a list of fields that will always be exposed.
```elixir
expose :name, as: :alias
```
The field lookup takes several steps:
- first try `UserEntity.name(user)`
- next try `UserEntity.name(user, :alias)`
- next try `User.name(user)`
- next try `Map.fetch(user, :name)`
For example, we have a struct:
```elixir
defmodule User do
defstruct [:id, :name, :phone]
def names(struct) do
String.split(struct.name)
end
end
```
and we want to expose these fields:
```elixir
expose [:id, :name]
expose :phone
```
All values will be given using the last step: `Map.fetch/2`. However, when we will expose a virtual field, which doesn't exist in a struct, then an entity will look up a value using a function, defined in the entity or in the module of the struct.
```elixir
expose :names
```
It will use `User.names(user)` to find value.
```elixir
expose :country_phone_code
def country_phone_code(struct) do
Phones.country_code(struct.phone)
end
```
And this will use `UserEntity.country_phone_code(user)`.
If the exposed field has an alias, you can also catch it inside the defined function:
```elixir
expose :amount
expose :amount, as: :amount_in_cents
def amount(struct, :amount),
do: struct.amount / 100
def amount(struct, :amount_in_cents),
do: struct.amount
```
```elixir
%{
amount: 109.99,
amount_in_cents: 10999
}
```
As you can notice, it's possible to pass options into the `expose` macro — full list of options listed below.
- `:as` - Expose under a different name.
- `:format` - Apply formatters before exposing a value.
- `:format_key` - Apply formatters to key before exposing a value.
- `:using` - Use another entity to represent a map or collection.
- `:if` - Conditional exposure. Use `:if` to give condition functions, and then the field will only be exposed if each function returns `true`.
- `:expose_nil` - Use this key to specify how `nil` values should be represented. Default: `true`
- `:merge` - Merge nested entity into the root map. Default: `false`
#### Formatting Values And Keys
```elixir
expose :name, format: &Utils.capitalize/1
expose :amount, format: [:to_s, &Money.format/1]
```
```elixir
%{
name: "Jon Stark",
amount: "$109.99"
}
```
```elixir
expose :full_name, format_key: :lcamelize
expose :amount, format_key: [:to_s, &String.reverse/1]
```
```elixir
%{
"fullName" => "jon stark",
"tnuoma" => 10999
}
```
It's also possible to set key formatters for the whole entity and they will be applied to all keys in the resulting map.
```elixir
format_keys :lcamelize
expose :some_long_field
expose :another_one_long_field
```
```elixir
%{
"someLongField" => "...",
"anotherOneLongField" => "..."
}
```
Entitiex provides a list of default formatters and you can reffer to them using a symbol. Default formatters are `:to_s`, `:to_atom`, `:upcase`, `:downcase`, `:camelize` and `:lcamelize`.
#### Using Nested Entities
There are two ways to work with nested structures in a source map. You can reuse your entities' modules using `:using` option.
```elixir
expose :address, using: AddressEntity
```
Or you can dynamically create an entity which should represent nested map using `inline` macro.
```elixir
inline :address do
expose :country
expose :city
expose :lines
end
```
There is also a way to create a nested structure in a resulting map from a flat source map.
```elixir
expose :name
nesting :company do
expose :company_name, as: :name
expose :company_address, as: :address, using: AddressEntity
end
```
Suppose we have such struct:
```elixir
defstruct [name: nil, company_name: nil, company_address: %Address{}]
```
So, the resulting map will be something like this:
```elixir
%{
name: "...",
company: %{
name: "...",
address: %{...}
}
}
```
The `nesting` and `inline` macros can also accept options, the same as `expose` macro.
```elixir
inline :address, if: :include_address? do
expose :country
expose :city
expose :lines
end
```
#### Conditional Exposure
Use `:if` to expose fields conditionally. It accepts only functions in the shape of `Module.function/arity` or atoms. Atoms will be compiled to normalized form (`Entity.atom/1`, `Entity.atom/2`) before compile time.
Also, it accepts an array of mentioned before types. An array of functions will be executed as a chain. All functions will be executed in the same order as they are listed in the array. All results will be aggregated.
```elixir
expose :charges, using: ChargesEntity, if: :billed?
expose :activity_state, if: &User.active?/1
expose :activity_history, if: [&User.active?/1, :can_see_history?]
```
#### Expose `nil` Values
By default, exposures that contain `nil` values will be represented in the resulting map as `nil`. You can override this behaviour using `:expose_nil` option. `nil` values won't be exposed when this option is set to `false`.
```elixir
expose :will_be_exposed_when_nil
expose :wont_be_exposed_when_nil, expose_nil: false
```
#### Merge Fields
Use :merge option to merge fields into the root map:
```elixir
inline :company, merge: true do
expose :name, as: :company_name
expose :email, as: :company_email
end
```
This will return something like:
```elixir
%{
company_name: "Super LLC",
company_email: "superhero@gmail.com"
}
```
#### `with_options` instruction
It's possible to define default options for a block of expose/inline/nesting commands.
```elixir
with_options expose_nil: false, if: :is_admin? do
expose :email
expose [:balance, :rate], format: &Money.format/1
expose :timeline, using: TimelineItemEntity
end
```
### Representation
```elixir
UserEntity.represent(user, root: :data, extra: [meta: %{additional: "data"}])
# => %{"data" => %{"id" => "1", ...}, "meta" => %{"additional" => "data"}}
UserEntity.represent([user], root: :data, extra: [meta: %{additional: "data"}])
# => %{"data" => [%{"id" => "1", ...}], "meta" => %{"additional" => "data"}}
UserEntity.represent([user], root: :data, context: %{user: current_user})
# => %{"data" => [%{"id" => "1", ...}]}
```
## Examples
```elixir
defmodule User do
defstruct [:id, :first_name, :last_name, :title, :locked, :roles, :company]
def addresses(_struct) do
[
%Address{id: 1, type: :home, country: "Russia", city: "Moscow", line: "7, Parkovaya st., Veshki, Altufievskoe haiway"},
%Address{id: 2, type: :work, country: "Thailand", city: "Phuket", line: "272, Land and House, Chao Fah Rd., Chalong"}
]
end
def contacts(_struct) do
[
%Contact{id: 2, type: :phone, line: "+70000000000"},
%Contact{id: 3, type: :email, line: "superhero@gmail.com"}
]
end
end
defmodule Company do
defstruct [:id, :name, :active]
end
defmodule Address do
defstruct [:id, :type, :country, :city, :line]
end
defmodule Contact do
defstruct [:id, :type, :line]
end
defmodule UserEntity do
use Entitiex.Enity
format_keys :lcamelize
expose :id, format: :to_s
expose :locked, as: :is_locked
expose :roles, format: :to_s
expose :full_name
nesting :name do
expose :first_name, as: :first
expose :last_name, as: :last
end
inline :company, if: :active_company? do
expose :id, format: :to_s
expose :name, format: [:to_s, &String.upcase/1]
end
expose :adresses, using: AddressEntity
expose :contacts, using: ContactEntity
def active_company?(_struct, company) do
company.active
end
def full_name(struct) do
"#{struct.first_name} #{struct.last_name}"
end
end
defmodule AddressEntity do
use Entitiex.Enity
format_keys :lcamelize
expose [:id, :type], format: :to_s
expose :country, as: :country_iso_code
expose :city
expose :line
def country(struct) do
Countries.iso_code(struct.country)
end
end
defmodule ContactEntity do
use Entitiex.Enity
format_keys :lcamelize
expose [:id, :type], format: :to_s
expose :line
end
```
```elixir
UserEntity.represent(user)
```
```elixir
%{
"id" => "1",
"isLocked" => true,
"roles" => ["member", "admin"],
"fullName" => "Super Hero",
"name" => %{
"first" => "Super",
"last" => "Hero"
},
"company" => %{
"id" => "12",
"name" => "SUPER LCC"
},
"addresses" => [
%{"id" => "1", "type" => "home", "countryIsoCode" => "RU", "city" => "Moscow", "line" => "7, Parkovaya st., Veshki, Altufievskoe haiway"},
%{"id" => "2", "type" => "work", "countryIsoCode" => "TH", "city" => "Phuket", "line" => "272, Land and House, Chao Fah Rd., Chalong"}
],
"contacts" => [
%{"id" => "2", "type" => "phone", "line" => "+70000000000"},
%{"id" => "3", "type" => "email", "line" => "superhero@gmail.com"}
]
}
```
```elixir
UserEntity.represent(user, root: :data, extra: [meta: %{additional: "data"}])
# => %{"data" => %{"id" => "1", ...}, "meta" => %{"additional" => "data"}}
UserEntity.represent([user], root: :data, extra: [meta: %{additional: "data"}])
# => %{"data" => [%{"id" => "1", ...}], "meta" => %{"additional" => "data"}}
UserEntity.represent([user], root: :data, context: %{user: current_user})
# => %{"data" => [%{"id" => "1", ...}]}
```
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at [https://hexdocs.pm/entitiex](https://hexdocs.pm/entitiex).