diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b0564c1..ebf80f8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @togethercomputer/elixir @togethercomputer/security +* @togethercomputer/elixir diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06ebb9d..fbef281 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/.vscode/settings.json b/.vscode/settings.json index 1580a06..7c0fc9b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { "cSpell.words": [ - "bingenerate" + "bingenerate", + "einval" ], "cSpell.ignoreWords": [ "ABCDEFGHJKLMNPQRSTUVWXY", diff --git a/lib/together/changeset.ex b/lib/together/changeset.ex new file mode 100644 index 0000000..5e733f1 --- /dev/null +++ b/lib/together/changeset.ex @@ -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 diff --git a/mix.exs b/mix.exs index b9d4fcd..6e91aa7 100644 --- a/mix.exs +++ b/mix.exs @@ -3,6 +3,7 @@ defmodule Together.MixProject do def project do [ + aliases: aliases(), app: :together, version: "0.1.0", elixir: "~> 1.18", @@ -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 diff --git a/mix.lock b/mix.lock index b2cb5b2..c4479dd 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, } diff --git a/test/together/changeset_test.exs b/test/together/changeset_test.exs new file mode 100644 index 0000000..c7cfedb --- /dev/null +++ b/test/together/changeset_test.exs @@ -0,0 +1,4 @@ +defmodule Together.ChangesetTest do + use ExUnit.Case + doctest Together.Changeset +end