Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1 +1 @@
* @togethercomputer/elixir @togethercomputer/security
* @togethercomputer/elixir
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ jobs:

- name: Check Unused Dependencies
run: mix deps.unlock --check-unused
if: always()

- name: Check Formatting
run: mix format --check-formatted
Expand All @@ -108,6 +109,10 @@ jobs:
run: mix credo
if: always()

- name: Check Sobelow
run: mix sobelow --config
if: always()

- name: Run Dialyzer
# Two formats are included for ease of debugging and it is lightly recommended to use both, see https://github.com/jeremyjh/dialyxir/issues/530 for reasoning
# --format github is helpful to print the warnings in a way that GitHub understands and can place on the /files page of a PR
Expand Down
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"cSpell.words": [
"bingenerate"
"bingenerate",
"einval"
],
"cSpell.ignoreWords": [
"ABCDEFGHJKLMNPQRSTUVWXY",
Expand Down
156 changes: 156 additions & 0 deletions lib/together/changeset.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
if Code.ensure_loaded?(Ecto.Changeset) do
defmodule Together.Changeset do
@moduledoc """
A module that provides a function to put a default value into a changeset.
"""
alias Ecto.Changeset

@doc """
Puts a default value for a field into a changeset if the field is not already set (`nil`)

## Examples

iex> changeset = Ecto.Changeset.change({%{}, %{email: :string}}, %{})
iex> Together.Changeset.put_default(changeset, :email, "example@example.com")
%Ecto.Changeset{
changes: %{email: "example@example.com"},
errors: [],
data: %{},
types: %{email: :string},
valid?: true
}

"""
@spec put_default(Changeset.t(), atom, term) :: Changeset.t()
def put_default(changeset, field, default) do
case Changeset.get_field(changeset, field) do
nil -> Changeset.put_change(changeset, field, default)
_value -> changeset
end
end

@doc """
Validates that at least one of the given fields is provided in the changeset

## Examples

iex> types = %{email: :string, password: :string}
iex> changeset = Ecto.Changeset.change({%{}, types}, %{email: nil, password: nil})
iex> Together.Changeset.validate_at_least_one_of(changeset, [:email, :password])
%Ecto.Changeset{
changes: %{},
errors: [
email: {"At least one of email, password must be provided", []},
password: {"At least one of email, password must be provided", []}
],
data: %{},
types: %{email: :string, password: :string},
valid?: false
}

"""
@spec validate_at_least_one_of(Changeset.t(), [atom]) :: Changeset.t()
def validate_at_least_one_of(changeset, []), do: changeset

def validate_at_least_one_of(changeset, fields) do
all_fields_missing? =
Enum.all?(fields, fn field ->
changeset
|> Changeset.get_field(field)
|> is_nil()
end)

if all_fields_missing? do
message = "At least one of #{Enum.join(fields, ", ")} must be provided"

for field <- Enum.reverse(fields), reduce: changeset do
changeset -> Changeset.add_error(changeset, field, message)
end
else
changeset
end
end

@doc """
Validate email address format in a changeset

## Examples

iex> changeset = Ecto.Changeset.change({%{}, %{email: :string}}, %{email: nil})
iex> Together.Changeset.validate_email_address(changeset, :email).valid?
true

iex> changeset = Ecto.Changeset.change({%{}, %{email: :string}}, %{email: "example@example.com"})
iex> Together.Changeset.validate_email_address(changeset, :email).valid?
true

iex> changeset = Ecto.Changeset.change({%{}, %{email: :string}}, %{email: "example.com"})
iex> Together.Changeset.validate_email_address(changeset, :email).valid?
false

iex> changeset = Ecto.Changeset.change({%{}, %{email: :string}}, %{email: "a@example.com."})
iex> Together.Changeset.validate_email_address(changeset, :email).valid?
false

"""
@spec validate_email_address(Changeset.t(), atom()) :: Changeset.t()
def validate_email_address(changeset, field) do
Changeset.validate_format(
changeset,
field,
# https://www.tempmail.us.com/en/elixir/implementing-w3c-compliant-email-validation-in-elixir
~r/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
message: "is not a valid email address"
)
end

@doc """
Validate email domain format in a changeset

## Examples

iex> changeset = Ecto.Changeset.change({%{}, %{email: :string}}, %{email: "example@example.com"})
iex> Together.Changeset.validate_email_domain(changeset, :email).valid?
false

iex> changeset = Ecto.Changeset.change({%{}, %{email: :string}}, %{email: "example.com"})
iex> Together.Changeset.validate_email_domain(changeset, :email).valid?
true

iex> changeset = Ecto.Changeset.change({%{}, %{email: :string}}, %{email: "example.com."})
iex> Together.Changeset.validate_email_domain(changeset, :email).valid?
false

"""
@spec validate_email_domain(Changeset.t(), atom()) :: Changeset.t()
def validate_email_domain(changeset, field) do
changeset
|> Changeset.validate_format(
field,
# https://www.tempmail.us.com/en/elixir/implementing-w3c-compliant-email-validation-in-elixir
~r/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
message: "is not a valid email domain"
)
|> validate_domain_structure(field)
end

@spec validate_domain_structure(Changeset.t(), atom()) :: Changeset.t()
defp validate_domain_structure(changeset, field) do
address = Changeset.get_field(changeset, field)

cond do
not is_binary(address) ->
changeset

String.contains?(address, "..") ->
Changeset.add_error(changeset, field, "is not a valid email domain")

String.starts_with?(address, ".") or String.ends_with?(address, ".") ->
Changeset.add_error(changeset, field, "is not a valid email domain")

:else ->
changeset
end
end
end
end
25 changes: 24 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ defmodule Together.MixProject do

def project do
[
aliases: aliases(),
app: :together,
version: "0.1.0",
elixir: "~> 1.18",
Expand Down Expand Up @@ -36,7 +37,29 @@ defmodule Together.MixProject do
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
{:ecto, "~> 3.5", optional: true},
# Available in `test` to avoid recompilation in CI just for docs
{:ex_doc, "~> 0.38", only: [:dev, :test], runtime: false}
{:ex_doc, "~> 0.38", only: [:dev, :test], runtime: false},
{:sobelow, "~> 0.13", only: [:dev, :test], runtime: false}
]
end

defp aliases do
[
check: [
"test --warnings-as-errors",
"deps.unlock --check-unused",
"format --check-formatted",
"credo",
"sobelow --config",
"dialyzer --format dialyxir"
]
]
end

def cli do
[
preferred_envs: [
check: :test
]
]
end

Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [: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", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
"makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
}
4 changes: 4 additions & 0 deletions test/together/changeset_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
defmodule Together.ChangesetTest do
use ExUnit.Case
doctest Together.Changeset
end
Loading