Skip to content

Commit 6416d8f

Browse files
committed
Added the Typed Contract Behaviour to guarantee data correcteness at runtime.
Signed-off-by: Exadra37 <exadra37@gmail.com>
1 parent bdd691c commit 6416d8f

File tree

6 files changed

+340
-1
lines changed

6 files changed

+340
-1
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
defmodule Persona do
2+
@moduledoc false
3+
4+
require PersonaValidator
5+
6+
@keys %{
7+
required: [:name, :email],
8+
optional: [role: nil]
9+
}
10+
11+
use ElixirScribe.Behaviour.TypedContract, keys: @keys
12+
13+
@impl true
14+
def type_spec() do
15+
schema(%__MODULE__{
16+
name: is_binary() |> spec(),
17+
email: PersonaValidator.corporate_email?() |> spec(),
18+
role: PersonaValidator.role?() |> spec()
19+
})
20+
end
21+
end
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
defmodule PersonaValidator do
2+
@moduledoc false
3+
4+
# A very simplistic set o validations used by the docs usage examples.
5+
6+
@email_providers ["gmail.com", "yahoo.com", "hotmail.com"]
7+
def corporate_email?(email) when is_binary(email) do
8+
case String.split(email, "@", trim: true) do
9+
[_one_part] ->
10+
false
11+
12+
[_, email_provider] when email_provider not in @email_providers ->
13+
true
14+
15+
_ ->
16+
false
17+
end
18+
end
19+
20+
def corporate_email?(_email), do: false
21+
22+
# The role is optional, thus we return true
23+
def role?(role) when is_nil(role), do: true
24+
25+
def role?(role) when is_binary(role) do
26+
role = role |> String.trim()
27+
String.length(role) >= 3
28+
end
29+
30+
def role?(_age), do: false
31+
end
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
defmodule ElixirScribe.Behaviour.TypedContract do
2+
@moduledoc """
3+
The Elixir Scribe Typed Contract guarantees the shape of your data throughout your codebase.
4+
5+
Use it as a contract for your data shape to eliminate potential bugs that may be caused by creating the data with the wrong type. This leads to more robust code and fewer tests needed to guarantee data correctness, compared to when a struct or a plain map was previously used.
6+
7+
8+
## Typed Contract
9+
10+
Let's imagine that the Business requested a new feature, a Marketing funnel where they only want to allow personas who have a company email to enter the funnel, and they require them to provide a name, email and optionally their role in the company.
11+
12+
We can use a Typed Contract to translate these business rules into a contract that guarantees data correctness across all our code base.
13+
14+
For example:
15+
16+
```
17+
#{File.read!("lib/docs/modules/behaviours/persona.ex")}
18+
```
19+
> ### Specs {: .info}
20+
> - When defining the `type_spec/0` the schema can use the built-in specs from [Norm](https://hexdocs.pm/norm/Norm.html#module-validation-and-conforming-values) or your own ones.
21+
> - All specs defined with `PersonaValidator.*` are custom ones.
22+
23+
24+
> ### Required and Optional Keys {: .warning}
25+
> - A typed contract needs to declare the required and optional keys, plus a type spec for them.
26+
> - The optional keys **MUST** have a default value.
27+
28+
> ### Contract Guarantees {: .error}
29+
> - To take advantage of the safety guarantees offered by the Elixir Scribe Typed Contract, you **MUST** not create or update it directly, as allowed by Elixir. Instead, you need to use the built-in functions `new/1`, `new!/1`, `update/3`, or `update!/3` that will guarantee it conforms with the type specs when one is created or updated.
30+
> - Use the `conforms?/1` function at the place you use the typed contract to ensure that you still have a struct that conforms with the type spec, because it may have been manipulated directly between the point it was created and where you are using it.
31+
> - For introspection, the `fields/0` and `type_spec/0` functions are provided.
32+
33+
34+
## Usage Examples
35+
36+
To run the usage examples:
37+
38+
```
39+
iex -S mix
40+
```
41+
42+
### With Valid Data Types
43+
44+
To create a new Person:
45+
46+
```
47+
iex> Persona.new! %{name: "Paulo", email: "exadra37@company.com"}
48+
%Persona{name: "Paulo", email: "exadra37@company.com", role: nil, self: Persona}
49+
```
50+
51+
To update the Person:
52+
53+
```
54+
iex> persona = Persona.new! %{name: "Paulo", email: "exadra37@company.com"}
55+
%Persona{name: "Paulo", email: "exadra37@company.com", role: nil, self: Persona}
56+
iex> persona.self.update! persona, :role, "Elixir Scribe Engineer"
57+
%Persona{
58+
name: "Paulo",
59+
email: "exadra37@company.com",
60+
role: "Elixir Scribe Engineer",
61+
self: Persona
62+
}
63+
```
64+
65+
Later, some layers deep in the code we can confirm that the Persona still conforms with the type spec defined in the contract:
66+
67+
```
68+
iex> persona = Persona.new! %{name: "Paulo", email: "exadra37@company.com"}
69+
%Persona{name: "Paulo", email: "exadra37@company.com", role: nil, self: Persona}
70+
iex> persona.self.conforms? persona
71+
true
72+
```
73+
74+
Alternatively, you can use `new/1` and `update/3` which will return a `{:ok, result}` or `{:error, reason}` tuple.
75+
76+
77+
### With Invalid Data Types
78+
79+
Passing the role as an atom, instead of the expected string:
80+
81+
```
82+
iex> Persona.new %{name: "Paulo", email: "exadra37@company.com", role: :cto}
83+
{:error, [%{input: :cto, path: [:role], spec: "PersonaValidator.role?()"}]}
84+
```
85+
86+
The same as above, but using `new!/1` which will raise:
87+
88+
```
89+
Persona.new! %{name: "Paulo", email: "exadra37@company.com", role: :cto}
90+
```
91+
92+
It will raise the following:
93+
94+
```
95+
** (Norm.MismatchError) Could not conform input:
96+
val: :cto in: :role fails: PersonaValidator.role?()
97+
(norm 0.13.0) lib/norm.ex:65: Norm.conform!/2
98+
iex:6: (file)
99+
```
100+
101+
Invoking `update/3` and `update!/3` will output similar results.
102+
103+
> ### Less Tests and Fewer Bugs {: .tip}
104+
> - The Elixir Scribe typed contract acts as a contract for the businesse rules to guarantee data correctness at anypoint it's used in the code base.
105+
> - Now the developer only needs to test that a Persona complies with this business rules in the test for this contract, because everywhere the Persona contract is used it's guaranteed that the data is in the expected shape.
106+
> - This translates to fewer bugs, less technical debt creeping in and a more robust code base.
107+
108+
### Introspection
109+
110+
To introspect the fields used to define the typed contract at compile time:
111+
112+
```
113+
iex> Persona.fields
114+
%{
115+
config: %{
116+
extra: [self: Persona],
117+
required: [:name, :email],
118+
optional: [role: nil]
119+
},
120+
defstruct: [:name, :email, {:role, nil}, {:self, Persona}]
121+
}
122+
```
123+
124+
To introspect the Norm type spec:
125+
126+
```
127+
iex> Persona.type_spec
128+
```
129+
130+
The output:
131+
132+
```
133+
#Norm.Schema<%Persona{
134+
name: #Norm.Spec<is_binary()>,
135+
email: #Norm.Spec<PersonaValidator.corporate_email?()>,
136+
role: #Norm.Spec<PersonaValidator.role?()>,
137+
self: Persona
138+
}>
139+
```
140+
141+
The `:self` field is an extra added at compile time by the typed contract macro to allow you to self reference the typed contract like you already noticed in the above examples.
142+
"""
143+
144+
alias ElixirScribe.Behaviour.TypedContract
145+
146+
@doc """
147+
Returns the type specification for the Elixir Scribe Typed Contract.
148+
149+
Used internally by all this behaviour callbacks, but may also be useful in the case more advanced usage of Norm is required outside this struct.
150+
"""
151+
@callback type_spec() :: struct()
152+
153+
@doc """
154+
Returns the fields definition used to define the struct for the Elixir Scribe Typed Contract at compile time.
155+
"""
156+
@callback fields() :: map()
157+
158+
@doc """
159+
Accepts a map with the attributes to create an Elixir Scribe Typed Contract.
160+
161+
Returns `{:ok, struct}` on successful creation, otherwise returns `{:error, reason}`.
162+
"""
163+
@callback new(map()) :: {:ok, struct()} | {:error, list(map())}
164+
165+
@doc """
166+
Accepts a map with the attributes to create an ELixir Scribe Typed Contract.
167+
168+
Returns the typed contract on successful creation, otherwise raises an error.
169+
"""
170+
@callback new!(map()) :: struct()
171+
172+
@doc """
173+
Updates the Elixir Scribe Typed Contract for the given key and value.
174+
175+
Returns `{:ok, struct}` on successful update, otherwise returns `{:error, reason}`.
176+
"""
177+
@callback update(struct(), atom(), any()) :: {:ok, struct()} | {:error, list(map())}
178+
179+
@doc """
180+
Updates the Elixir Scribe Typed Contract for the given key and value.
181+
182+
Returns the typed contract on successful update, otherwise raises an error.
183+
"""
184+
@callback update!(struct(), atom(), any()) :: struct()
185+
186+
@doc """
187+
Accepts the Elixir Scribe Typed Contract itself to check it still conforms with the specs.
188+
189+
Creating or modifying the struct directly can cause it to not be conformant anymore with the specs.
190+
191+
Useful to use by modules operating on the Typed Contract to ensure it wasn't directly modified after being created with `new/1` or `new!/1`.
192+
"""
193+
@callback conforms?(struct()) :: boolean()
194+
195+
def __optional_fields__(%{optional: optional, extra: extra}, module) do
196+
Keyword.keyword?(optional) ||
197+
raise """
198+
#{module}
199+
200+
All optional fields in the Typed Contract MUST have a default value:
201+
202+
* Incorrect: [:a, :b] or [:a, b: :default]
203+
204+
* Correct: [a: :default, b: :default]
205+
206+
Your fields:
207+
208+
#{inspect(optional)}
209+
"""
210+
211+
optional ++ extra
212+
end
213+
214+
def __optional_fields__(%{extra: extra}, _module), do: extra
215+
216+
defmacro __using__(opts) do
217+
moduledocs = @moduledoc
218+
219+
quote location: :keep, bind_quoted: [opts: opts, moduledocs: moduledocs] do
220+
unless Module.get_attribute(__MODULE__, :moduledoc, false) do
221+
@moduledoc """
222+
Module visible without docs.
223+
224+
> #### INFO {: .info}
225+
> This module doesn't have docs, but you can read instead the docs for the behaviour it implements: `ElixirScribe.Behaviour.TypedContract`
226+
"""
227+
end
228+
229+
import Norm
230+
231+
@behaviour ElixirScribe.Behaviour.TypedContract
232+
233+
@struct_keys Keyword.get(opts, :keys, %{}) |> Map.put(:extra, self: __MODULE__)
234+
235+
@enforce_keys @struct_keys.required
236+
237+
@optional_keys TypedContract.__optional_fields__(@struct_keys, __MODULE__)
238+
239+
@all_keys @enforce_keys ++ @optional_keys
240+
241+
@fields %{defstruct: @all_keys, config: @struct_keys}
242+
243+
defstruct @all_keys
244+
245+
@impl true
246+
def fields(), do: @fields
247+
248+
@impl true
249+
def new(attrs) when is_map(attrs) do
250+
struct(__MODULE__, attrs)
251+
|> conform(type_spec())
252+
end
253+
254+
@impl true
255+
def new!(attrs) when is_map(attrs) do
256+
struct(__MODULE__, attrs)
257+
|> conform!(type_spec())
258+
end
259+
260+
@impl true
261+
def update(%__MODULE__{} = typed_contract, key, value) do
262+
typed_contract
263+
|> Map.put(key, value)
264+
|> conform(type_spec())
265+
end
266+
267+
@impl true
268+
def update!(%__MODULE__{} = typed_contract, key, value) do
269+
typed_contract
270+
|> Map.put(key, value)
271+
|> conform!(type_spec())
272+
end
273+
274+
@impl true
275+
def conforms?(%__MODULE__{} = typed_contract), do: typed_contract |> valid?(type_spec())
276+
def conforms?(_), do: false
277+
278+
defoverridable new: 1, new!: 1, update: 3, update!: 3, conforms?: 1
279+
end
280+
end
281+
end

mix.exs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ defmodule ElixirScribe.MixProject do
5959
[
6060
# {:dep_from_hexpm, "~> 0.3.0"},
6161
# {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}
62-
{:ex_doc, ">= 0.0.0", only: :dev, runtime: false}
62+
{:ex_doc, ">= 0.0.0", only: :dev, runtime: false},
63+
{:norm, "~> 0.13"}
6364
]
6465
end
6566
end

mix.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@
55
"makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"},
66
"makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"},
77
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
8+
"norm": {:hex, :norm, "0.13.0", "2c562113f3205e3f195ee288d3bd1ab903743e7e9f3282562c56c61c4d95dec4", [:mix], [{:stream_data, "~> 0.5", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "447cc96dd2d0e19dcb37c84b5fc0d6842aad69386e846af048046f95561d46d7"},
89
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
defmodule ElixirScribe.Behaviour.TypedContractTest do
2+
use ExUnit.Case, async: true
3+
doctest ElixirScribe.Behaviour.TypedContract
4+
end

0 commit comments

Comments
 (0)