Skip to content

Commit

Permalink
More stuff
Browse files Browse the repository at this point in the history
  • Loading branch information
edgurgel committed Aug 17, 2024
1 parent 18e4c7e commit b8a482e
Show file tree
Hide file tree
Showing 4 changed files with 111 additions and 38 deletions.
69 changes: 60 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
[![License](https://img.shields.io/hexpm/l/ham.svg)](https://github.com/edgurgel/ham/blob/master/LICENSE)
[![Last Updated](https://img.shields.io/github/last-commit/edgurgel/ham.svg)](https://github.com/edgurgel/ham/commits/master)

Ham is a library to validate function arguments and return values against their typespecs. It was originally extracted out
from [Ham](https://github.com/msz/hammox/) but without the Mox integration. Ham - Mox = Ham!
Ham is a library to validate function arguments and return values against their typespecs.
It was originally extracted out from [Ham](https://github.com/msz/hammox/) but
without the Mox integration. Hammox - Mox = Ham!
Thanks @msz for creating Hammox!

The main reason why I wanted to extract it out is so that it can be used as part of Mimic or other testing libraries.
Expand All @@ -25,16 +26,66 @@ def deps do
end
```

## Why use Ham for my application code when I have Dialyzer?
## Using Ham

Dialyzer is a powerful static analysis tool that can uncover serious problems.
One can simply let Ham apply a module function using `Ham.apply/2` (function or macro):

```elixir
iex> Ham.apply(URI, :decode, ["https%3A%2F%2Felixir-lang.org"])
"https://elixir-lang.org"
iex> Ham.apply(URI, :char_reserved?, ["a"])
** (Ham.TypeMatchError) 1st argument value "a" does not match 1st parameter's type byte().
Value "a" does not match type 0..255.
iex> Ham.apply(URI.decode("https%3A%2F%2Felixir-lang.org"))
"https://elixir-lang.org"
iex> Ham.apply(URI.char_reserved?("a"))
** (Ham.TypeMatchError) 1st argument value "a" does not match 1st parameter's type byte().
Value "a" does not match type 0..255.
```

Another way is to pass the args and return value without Ham executing anything:

```elixir
iex> Ham.validate(URI, :char_reserved?, ["a"], true)
{:error,
%Ham.TypeMatchError{
reasons: [
{:arg_type_mismatch, 0, "a", {:type, {324, 24}, :byte, []}},
{:type_mismatch, "a", {:type, 0, :range, [{:integer, 0, 0}, {:integer, 0, 255}]}}
]
}}

iex> Ham.validate!(URI, :char_reserved?, ["a"], true)
** (Ham.TypeMatchError) 1st argument value "a" does not match 1st parameter's type byte().
Value "a" does not match type 0..255.
```
Both `apply` and `validate` accept `behaviours` as an option to declare that the module
implements certain behaviours. Ham is capable of importing the typespecs for the callbacks
if a typespec is explicitly defined.
But during tests dialyzer is not as useful. If Dialyzer is used with Mimic or another mock
library then dialyzer won't help as it will not be able to easily typecheck the mocks
generated during testing.
For example let's implement a module that does a poor job of implementing `Access`

Hopefully in the [near future](https://elixir-lang.org/blog/2023/06/22/type-system-updates-research-dev/)
Elixir will have a better type system so we will not need Ham anymore.
```elixir
defmodule CustomAccess do
@behaviour Access

def fetch(_data, _key), do: :poor
def get_and_update(_data, _key, _function), do: :poor
def pop(data, key), do: :poor
end

Ham.apply(CustomAccess.fetch([], "key"), behaviours: [Access])
** (Ham.TypeMatchError) Returned value :poor does not match type {:ok, Access.value()} | :error.
Value :wrong does not match type {:ok, Access.value()} | :error.
```

## Why use Ham for my application code when I have Dialyzer?

Dialyzer is a powerful static analysis tool that can uncover serious problems.
But during tests dialyzer is not as useful. Ham can be used to validate function arguments
and return values during tests even if they are randomly generated or loaded from files.

## Protocol types

Expand Down
46 changes: 22 additions & 24 deletions lib/ham.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ defmodule Ham do
@doc """
Apply a module function validating arguments and return value against typespecs
iex> Ham.apply(URI.decode("https%3A%2F%2Felixir-lang.org"))
iex> Ham.apply(URI, :decode, ["https%3A%2F%2Felixir-lang.org"])
"https://elixir-lang.org"
iex> Ham.apply(URI, :char_reserved?, ["a"])
** (Ham.TypeMatchError) 1st argument value "a" does not match 1st parameter's type byte().
Expand All @@ -22,6 +22,27 @@ defmodule Ham do
return_value
end

@doc """
Handy macro to apply a module function validating arguments and return value against typespecs
iex> Ham.apply(URI.decode("https%3A%2F%2Felixir-lang.org"))
"https://elixir-lang.org"
iex> Ham.apply(URI.char_reserved?("a"))
** (Ham.TypeMatchError) 1st argument value "a" does not match 1st parameter's type byte().
Value "a" does not match type 0..255.
"""
defmacro apply(call, opts \\ []) do
case Macro.decompose_call(call) do
:error ->
raise ArgumentError, "Invalid call"

{module, function_name, args} ->
quote bind_quoted: [module: module, function_name: function_name, args: args, opts: opts] do
Ham.apply(module, function_name, args, opts)
end
end
end

@doc """
Validate arguments and return value against typespecs.
Expand Down Expand Up @@ -55,27 +76,4 @@ defmodule Ham do
{:error, error} -> raise error
end
end

defmacro apply(call, opts \\ []) do
case Macro.decompose_call(call) do
:error ->
raise ArgumentError, "Invalid call"

{module, function_name, args} ->
quote bind_quoted: [module: module, function_name: function_name, args: args, opts: opts] do
Ham.apply(module, function_name, args, opts)
end
end

# {
# module,
# function_name,
# args
# }
# {
# {:__aliases__, [alias: false], [:URI]},
# :encode,
# [{:%{}, [], [a: "b"]}]
# }
end
end
21 changes: 16 additions & 5 deletions test/ham_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@ defmodule HamTest do
require Ham

describe "apply/2" do
test "valid calls" do
assert Ham.apply(URI.decode("https%3A%2F%2Felixir-lang.org")) == "https://elixir-lang.org"
assert Ham.apply(URI.char_reserved?(?c)) == false
test "with behaviours" do
message = """
Returned value :wrong does not match type {:ok, Access.value()} | :error.
Value :wrong does not match type {:ok, Access.value()} | :error.\
"""

assert_raise(Ham.TypeMatchError, message, fn ->
Ham.apply(CustomAccess.fetch([], "key"), behaviours: [Access])
end)

assert Ham.apply(TestModule.foo_number()) == 1
assert Ham.apply(CustomAccess.pop([], "key"), behaviours: [Access]) == :wrong
end

test "invalid call" do
Expand All @@ -21,10 +27,15 @@ defmodule HamTest do
assert_raise(Ham.TypeMatchError, message, fn ->
Ham.apply(URI.char_reserved?("a"))
end)

assert_raise(Ham.TypeMatchError, message, fn ->
Ham.apply(URI, :char_reserved?, ["a"])
end)
end
end

describe "validate/5" do
# FIXME
test "valid args and return value" do
end
end
end
13 changes: 13 additions & 0 deletions test/support/module.ex
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,16 @@ defmodule Ham.Test.TestModule do

def nospec_fun, do: :ok
end

defmodule CustomAccess do
@behaviour Access

@impl Access
def fetch(_data, _key), do: :wrong
@impl Access
def get_and_update(_data, _key, _function), do: :wrong

@impl Access
@spec pop(any, any) :: :wrong
def pop(_data, _key), do: :wrong
end

0 comments on commit b8a482e

Please sign in to comment.