Giter Club home page Giter Club logo

goal's Introduction

Goal

Goal is a parameter validation library based on Ecto. It can be used with JSON APIs, HTML controllers and LiveViews.

Goal builds a changeset from a validation schema and controller or LiveView parameters, and returns the validated parameters or Ecto.Changeset, depending on the function you use.

If your frontend and backend use different parameter cases, you can recase parameter keys with the :recase_keys option. PascalCase, camelCase, kebab-case and snake_case are supported.

You can configure your own regexes for password, email, and URL format validations. This is helpful in case of backward compatibility, where Goal's defaults might not match your production system's behavior.

Installation

Add goal to the list of dependencies in mix.exs:

def deps do
  [
    {:goal, "~> 0.3"}
  ]
end

Examples

Goal can be used with LiveViews and JSON and HTML controllers.

Example with controllers

With JSON and HTML-based APIs, Goal takes the params from a controller action, validates those against a validation schema using validate/3, and returns an atom-based map or an error changeset.

defmodule MyApp.SomeController do
  use MyApp, :controller
  use Goal

  def create(conn, params) do
    with {:ok, attrs} <- validate(:create, params)) do
      ...
    else
      {:error, changeset} -> {:error, changeset}
    end
  end

  defparams :create do
    required :uuid, :string, format: :uuid
    required :name, :string, min: 3, max: 3
    optional :age, :integer, min: 0, max: 120
    optional :gender, :enum, values: ["female", "male", "non-binary"]
    optional :hobbies, {:array, :string}, max: 3, rules: [trim: true, min: 1]

    optional :data, :map do
      required :color, :string
      optional :money, :decimal
      optional :height, :float
    end
  end
end

Example with LiveViews

With LiveViews, Goal builds a changeset in mount/3 that is assigned in the socket, and then it takes the params from handle_event/3, validates those against a validation schema, and returns an atom-based map or an error changeset.

defmodule MyApp.SomeLiveView do
  use MyApp, :live_view
  use Goal

  def mount(params, _session, socket) do
    changeset = changeset(:new, %{})
    socket = assign(socket, :changeset, changeset)

    {:ok, socket}
  end

  def handle_event("validate", %{"some" => params}, socket) do
    changeset = changeset(:new, params)
    socket = assign(socket, :changeset, changeset)

    {:noreply, socket}
  end

  def handle_event("save", %{"some" => params}, socket) do
    with {:ok, attrs} <- validate(:new, params)) do
      ...
    else
      {:error, changeset} -> {:noreply, assign(socket, :changeset, changeset)}
    end
  end

  defparams :new do
    required :uuid, :string, format: :uuid
    required :name, :string, min: 3, max: 3
    optional :age, :integer, min: 0, max: 120
    optional :gender, :enum, values: ["female", "male", "non-binary"]
    optional :hobbies, {:array, :string}, max: 3, rules: [trim: true, min: 1]

    optional :data, :map do
      required :color, :string
      optional :money, :decimal
      optional :height, :float
    end
  end
end

Example with isolated schemas

Validation schemas can be defined in a separate namespace, for example MyAppWeb.MySchema:

defmodule MyAppWeb.MySchema do
  use Goal

  defparams :show do
    required :id, :string, format: :uuid
    optional :query, :string
  end
end

defmodule MyApp.SomeController do
  use MyApp, :controller

  alias MyAppWeb.MySchema

  def show(conn, params) do
    with {:ok, attrs} <- MySchema.validate(:show, params) do
      ...
    else
      {:error, changeset} -> {:error, changeset}
    end
  end
end

Features

Presence checks

Sometimes all you need is to check if a parameter is present:

use Goal

defparams :show do
  required :id
  optional :query
end

Deeply nested maps

Goal efficiently builds error changesets for nested maps, and has support for lists of nested maps. There is no limitation on depth.

use Goal

defparams :show do
  optional :nested_map, :map do
    required :id, :integer
    optional :inner_map, :map do
      required :id, :integer
      optional :map, :map do
        required :id, :integer
        optional :list, {:array, :integer}
      end
    end
  end
end

iex(1)> validate(:show, params)
{:ok, %{nested_map: %{inner_map: %{map: %{id: 123, list: [1, 2, 3]}}}}}

Powerful array validations

If you need expressive validations for arrays types, look no further!

Arrays can be made optional/required or the number of items can be set via min, max and is. Additionally, rules allows specifying any validations that are available for the inner type. Of course, both can be combined:

use Goal

defparams do
  required :my_list, {:array, :string}, max: 2, rules: [trim: true, min: 1]
end

iex(1)> Goal.validate_params(schema(), %{"my_list" => ["hello ", " world "]})
{:ok, %{my_list: ["hello", "world"]}}

Readable error messages

Use Goal.traverse_errors/2 to build readable errors. Phoenix by default uses Ecto.Changeset.traverse_errors/2, which works for embedded Ecto schemas but not for the plain nested maps used by Goal. Goal's traverse_errors/2 is compatible with (embedded) Ecto.Schema, so you don't have to make any changes to your existing logic.

def translate_errors(changeset) do
  Goal.traverse_errors(changeset, &translate_error/1)
end

Recasing inbound keys

By default, Goal will look for the keys defined in defparams. But sometimes frontend applications send parameters in a different format. For example, in camelCase but your backend uses snake_case. For this scenario, Goal has the :recase_keys option:

config :goal,
  recase_keys: [from: :camel_case]

iex(1)> MySchema.validate(:show, %{"firstName" => "Jane"})
{:ok, %{first_name: "Jane"}}

Recasing outbound keys

Use recase_keys/2 to recase outbound keys. For example, in your views:

config :goal,
  recase_keys: [to: :camel_case]

defmodule MyAppWeb.UserJSON do
  import Goal

  def show(%{user: user}) do
    recase_keys(%{data: %{first_name: user.first_name}})
  end

  def error(%{changeset: changeset}) do
    recase_keys(%{errors: Goal.Changeset.traverse_errors(changeset, &translate_error/1)})
  end
end

iex(1)> UserJSON.show(%{user: %{first_name: "Jane"}})
%{data: %{firstName: "Jane"}}
iex(2)> UserJSON.error(%Ecto.Changeset{errors: [first_name: {"can't be blank", [validation: :required]}]})
%{errors: %{firstName: ["can't be blank"]}}

Bring your own regex

Goal has sensible defaults for string format validation. If you'd like to use your own regex, e.g. for validating email addresses or passwords, then you can add your own regex in the configuration:

config :goal,
  uuid_regex: ~r/^[[:alpha:]]+$/,
  email_regex: ~r/^[[:alpha:]]+$/,
  password_regex: ~r/^[[:alpha:]]+$/,
  url_regex: ~r/^[[:alpha:]]+$/

Available validations

The field types and available validations are:

Field type Validations Description
:uuid :equals string value
:string :equals string value
:is exact string length
:min minimum string length
:max maximum string length
:trim oolean to remove leading and trailing spaces
:squish boolean to trim and collapse spaces
:format :uuid, :email, :password, :url
:subset list of required strings
:included list of allowed strings
:excluded list of disallowed strings
:integer :equals integer value
:is integer value
:min minimum integer value
:max maximum integer value
:greater_than minimum integer value
:less_than maximum integer value
:greater_than_or_equal_to minimum integer value
:less_than_or_equal_to maximum integer value
:equal_to integer value
:not_equal_to integer value
:subset list of required integers
:included list of allowed integers
:excluded list of disallowed integers
:float all of the integer validations
:decimal all of the integer validations
:boolean :equals boolean value
:date :equals date value
:time :equals time value
:enum :values list of allowed values
:map :properties use :properties to define the fields
{:array, :map} :properties use :properties to define the fields
{:array, inner_type} :rules inner_type can be any basic type. rules supported all validations available for inner_type
:min minimum array length
:max maximum array length
:is exact array length
More basic types See Ecto.Schema for the full list

All field types, excluding :map and {:array, :map}, can use :equals, :subset, :included, :excluded validations.

Credits

This library is based on Ecto and I had to copy and adapt Ecto.Changeset.traverse_errors/2. Thanks for making such an awesome library! ๐Ÿ™‡

goal's People

Contributors

dependabot[bot] avatar lucca65 avatar lukasknuth avatar martinthenth avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar

goal's Issues

Issue with negative numbers

Whenever I try to use a negative number of any kind (integer, decimal, float) i get an ArgumentError.
For example:

 required(:longitude, :float,
      min: -180.0,
      max: 180.0
    )

Fails with:

** (ArgumentError) expected option `greater_than_or_equal_to` to be a decimal, integer, or float, got: {:-, [line: 33, column: 12], [180.0]}

Support for passing a function or constant to enum values

First off, thanks so much for a wonderful library! We are currently using goal to validate params before sending to a 3rd party API and it's working great! One pain point we've run into is it's currently impossible to share enums across modules. Here's a minimal example that will error:

defmodule MyValidation do
  use Goal

  defp statuses, do: ["draft", "pending", "done"]

  defparams :index do
    optional :status, :enum, values: statuses()
  end

  defparams :update do
    optional :status, :enum, values: statuses()
  end
end

If you try to do the above, once the macro is expanded, you won't get the result of statuses() as a list of atoms. Instead it gets expanded to status: [type: :enum, values: {:{}, [], [:statuses, [line: 4], []]}].

The problem code seems to be here where the block passed to generate_schema/1 has been given a function tuple in place of statuses() instead of the result of a call to the function.

def schema(unquote(name)) do
  unquote(block |> generate_schema() |> Macro.escape())
end

Within generate_schema/1 I've tried a couple different things without luck, for example calling apply on the function tuple with __CALLER__ when values appears in the options provided. Just trying to get the function evaluated to a list of strings before later getting passed to get_types/1 and String.to_atom/1 which does not succeed.

  defp get_types(schema) do
    Enum.reduce(schema, %{}, fn {field, rules}, acc ->
      case Keyword.get(rules, :type, :any) do
        :enum ->
          values =
            rules
            |> Keyword.get(:values, [])
            |> Enum.map(&String.to_atom/1)

          Map.put(acc, field, {:parameterized, Ecto.Enum, Ecto.Enum.init(values: values)})

    # ...
  end

I'm a little out of my depth with the macros here, but any guidance or ideas would be much appreciated! Or if you have a quick solution/workaround, even better! Thanks in advance for your time!

Version 0.3.0

I have some changes ready and I'd like to release the recent changes made by @LukasKnuth:

  1. Release #50
  2. Release #53
  3. Add :any field type and set it as default ๐Ÿ—๏ธ
  4. Remove defschema in favor of defparams ๐Ÿ—๏ธ

Defaulting to :any would allow for proponents of the "check don't validate" parameter validation methodology to omit the types and simply check for presence. This change would make a breaking change for users of the basic syntax (not for the defparams users), because the current default is :string.

defschema is an earlier version of defparams that expands to the basic syntax. I'm not sure that anybody uses it anymore, because defparams is more convenient.

Allow specifying any regex to `format`

We'd still keep the pre-filled format options (like email, uuid, etz), but we'd also allow just giving any %Regex{} to the :format validation.

This allows some more flexibility:

required :id, :string, trim: true, format: ~r/^[a-z0-9-]+$/i

I have a working version that I can create a PR for as soon as main is clean again after #50

Thoughts?

Add `unique` transform for Arrays

This would work similar to how trim works on strings. It doesn't really validate anything but modifies the input value by removing any duplicate array items.

required :options, {:array, :string}, unique: true

I'd expect this to work in order with the other defined validations, so defining rules: [trim: true], unique: true would first trim all items in the array and then remove any repeated items.

What do you think?

Support for flexible schema

Would this be able to support use case like:

if param foo is true, we require param bar to be given (required) if not bar is not required

Is it possible to get a atom key map for a map property?

For example:

use Goal

defparams :update do
  required(:translations, :map)
end

validate(:update, %{
  "translations" => %{"en" => %{"name" => "Foo"}}
})

# Returns
%{
  translations: %{en: %{name: "Foo"}}
}

Currently it returns

%{
  "translations" => %{"en" => %{"name" => "Foo"}}
}

Support {:array, type} with :rules for the inner type

Currently, I can do this:

defparams :update do
  required :options, {:array, :string}
end

This will validate that the values inside the :options array are strings. I'd like to propose the following notation:

defparams :update do
  required :options, {:array, :string}, rules: [min: 1, trim: true]

This would be allowed for the inner types string, integer, float, decimal, boolean, date, and time. Not sure if it's possible/useful for enum. For maps, we already have existing notation.

It runs the validations available for the inner type on each value inside the array and adds the errors accordingly.

Ideally, this can be implemented by just using the inner type, validating it as if it where a top-level parameter and merging the result back into the nested array.

Any input/considerations/feelings towards this proposal? I'll go ahead and hack at it a bit, see what I come up with.

Feature: `one_of_many` Validation

I really like this library and I would like to improve it.

I have the idea to add a one_of_many validation. As the name suggests it will check if only one of N fields is present.

Here's a simple example:

 defparams :login do
        optional(:email, :string)
        optional(:username, :string)
        required(:password, :string)

        one_of_many([:email, :username])
  end

Validate non-existing params

Hi!

First of all, great library! Many thanks! ๐Ÿ™๐Ÿป

I was wondering if there is (or will be) support for validating fields that do not occur in the defparams?

For example, suppose we have the following module:

defmodule Params do
  use Goal

  defparams :body do
    optional :field, :string
  end
end

When I run the following, I get:

iex> Params.validate(:body, %{non_existing_field: "..."})
{:ok, %{}}

I would like to have the option to return an error tuple saying something like :non_existing_field does not exist.

Thanks in advance!

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.