Is there a way to reuse argument constraints?

I’m trying to find out if there’s a way to reuse argument constraints rather than defining them over and over again. For example, say you have a User resource in an Accounts domain. On that resource there is an attribute for the password_hash. However, the user is inputting their password when interacting with the application in various ways, such as registering or updating their password. In both of those scenarios, I would need to run the password argument against a set of constraints to ensure it meets the password requirements. As a super basic example, both actions I would have something like this:

actions do
  create :register do
    argument :password, :string do
      allow_nil? false
      sensitive? true
      constraints min_length: 8, max_length: 72
    end
  end

  update :update_password do
    argument :password, :string do
      allow_nil? false
      sensitive? true
      constraints min_length: 8, max_length: 72
    end
  end
end

Is there a way to share the constraints in this situation so that when I make a password argument it will always point towards the same set of constraints? I would hate to have to duplicate this code and keep them in sync manually if there were any changes.

1 Like

Yep! This is the purpose of Ash.Type.NewType

defmodule MyApp.Types.Password do
  use Ash.Type.NewType, subtype_of: :string, constraints: [min_length: 8, max_length: 72]
end
    argument :password, MyApp.Types.Password do
      allow_nil? false
      sensitive? true
    end

You can also give it a short name like so:

config :ash, :custom_types, [password: MyApp.Types.Password]

And then its

    argument :password, :password do
      allow_nil? false
      sensitive? true
    end
2 Likes

Thanks for the quick response! That’s perfect. Follow up question, is there a way to give this NewType custom error messages for each constraint type? For example, if the password has an invalid format, saying something along the lines of "must contain special character"?

Not just by setting or modifying constraints, you’d need to validate the value yourself.

defmodule MyApp.Types.Password do
  use Ash.Type.NewType, subtype_of: :string

  def cast_input(nil, _constraints) do
    {:ok, nil}
  end

  def cast_input(value, _constraints) do
     # do your own logic here, returning things like `{:error, "must contain a special character"}` or `{:ok, value}`
  end
end
1 Like

Thanks! To ensure I’m doing this correctly, here’s a rough copy with a regex placeholder:

defmodule MyApp.Accounts.User.Types.Password do
  @moduledoc """
  A custom type for password validation
  """
  use Ash.Type.NewType, subtype_of: :string

  def cast_input(nil, _constraints) do
    {:ok, nil}
  end

  def cast_input(value, _constraints) when not is_binary(value) do
    {:error, "must be a string"}
  end

  def cast_input(value, _constraints) do
    cond do
      String.length(value) < 8 ->
        {:error, "must be at least 8 characters"}

      String.length(value) > 72 ->
        {:error, "must not exceed 72 characters"}

      not String.match?(value, ~r/.../) ->
        {:error, "must contain a special character"}

      true ->
        {:ok, value}
    end
  end
end

Is that about right? When I run it, it works as expected. When giving it non-string input, such as 123, it returns the error as expected. Should I instead be expected to cast the input into a string seeing as the name of the function is cast_input?

Its up to you :smiley: The builtin string type itself does not attempt to cast arbitrary values to strings, there is likely no need for you to do so either.

EDIT:

if you want to ensure parity with the builtin type, you could wrap your logic in something like:

with {:ok, value} <- Ash.Type.cast_input(:string, value, constraints) do
   ...
end
1 Like

Excellent! I truly appreciate all the help :pray: