{"id":13576279,"url":"https://github.com/solnic/drops","last_synced_at":"2025-05-15T12:05:10.734Z","repository":{"id":192357613,"uuid":"686925389","full_name":"solnic/drops","owner":"solnic","description":"🛠️ Tools for working with data effectively - data contracts using types, schemas, domain validation rules, type-safe casting, and more.","archived":false,"fork":false,"pushed_at":"2025-03-21T14:33:23.000Z","size":411,"stargazers_count":273,"open_issues_count":17,"forks_count":7,"subscribers_count":9,"default_branch":"main","last_synced_at":"2025-04-11T21:48:57.546Z","etag":null,"topics":["data","elixir","elixir-lang","elixir-library","json","schema","validation"],"latest_commit_sha":null,"homepage":"","language":"Elixir","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/solnic.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2023-09-04T08:36:15.000Z","updated_at":"2025-03-30T19:38:12.000Z","dependencies_parsed_at":"2023-12-13T20:07:15.226Z","dependency_job_id":"e4a1bacd-b760-448f-969c-452bfd3b0a92","html_url":"https://github.com/solnic/drops","commit_stats":{"total_commits":228,"total_committers":1,"mean_commits":228.0,"dds":0.0,"last_synced_commit":"762ea31b03ffdf6aa73b305670f52ed29a1150cd"},"previous_names":["solnic/drops"],"tags_count":4,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/solnic%2Fdrops","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/solnic%2Fdrops/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/solnic%2Fdrops/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/solnic%2Fdrops/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/solnic","download_url":"https://codeload.github.com/solnic/drops/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":254337612,"owners_count":22054253,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["data","elixir","elixir-lang","elixir-library","json","schema","validation"],"created_at":"2024-08-01T15:01:08.785Z","updated_at":"2025-05-15T12:05:05.562Z","avatar_url":"https://github.com/solnic.png","language":"Elixir","readme":"# Elixir Drops 💦\n[![CI](https://github.com/solnic/drops/actions/workflows/ci.yml/badge.svg)](https://github.com/solnic/drops/actions/workflows/ci.yml) [![Hex pm](https://img.shields.io/hexpm/v/drops.svg?style=flat)](https://hex.pm/packages/drops) [![hex.pm downloads](https://img.shields.io/hexpm/dt/drops.svg?style=flat)](https://hex.pm/packages/drops)\n\nElixir `Drops` is a collection of small modules that provide useful extensions and functions that can be used to work with data effectively.\n\n## Installation\n\nIf [available in Hex](https://hex.pm/docs/publish), the package can be installed by adding `drops` to your list of dependencies in `mix.exs`:\n\n```elixir\ndef deps do\n  [\n    {:drops, \"~\u003e 0.2.0\"}\n  ]\nend\n```\n\nDocumentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) and published on [HexDocs](https://hexdocs.pm). Once published, the docs can be found at \u003chttps://hexdocs.pm/drops\u003e.\n\n## Contracts\n\nYou can use `Drops.Contract` to define data coercion and validation schemas with arbitrary validation rules.\n\nHere's an example of a simple `UserContract` which defines two required keys and expected types:\n\n```elixir\ndefmodule UserContract do\n  use Drops.Contract\n\n  schema do\n    %{\n      required(:name) =\u003e string(),\n      required(:email) =\u003e string()\n    }\n  end\nend\n\nUserContract.conform(%{name: \"Jane\", email: \"jane@doe.org\"})\n# {:ok, %{name: \"Jane\", email: \"jane@doe.org\"}}\n\n{:error, errors} = UserContract.conform(%{email: 312})\n# {:error,\n#  [\n#    %Drops.Validator.Messages.Error.Type{\n#      path: [:email],\n#      text: \"must be a string\",\n#      meta: %{args: [:string, 312], predicate: :type?}\n#    },\n#    %Drops.Validator.Messages.Error.Key{\n#      path: [:name],\n#      text: \"key must be present\",\n#      meta: %{args: [:name], predicate: :has_key?}\n#    }\n#  ]}\n\nEnum.map(errors, \u0026to_string/1)\n# [\"email must be a string\", \"name key must be present\"]\n\n{:error, errors} = UserContract.conform(%{name: \"Jane\", email: 312})\n# {:error,\n#  [\n#    %Drops.Validator.Messages.Error.Type{\n#      path: [:email],\n#      text: \"must be a string\",\n#      meta: %{args: [:string, 312], predicate: :type?}\n#    }\n#  ]}\n\nEnum.map(errors, \u0026to_string/1)\n# [\"email must be a string\"]\n```\n\n## Schemas\n\nContract's schemas are a powerful way of defining the exact shape of the data you expect to work with. They are used to validate **the structure** and **the values** of the input data. Using schemas, you can define which keys are required and whic are optional, the exact types of the values and any additional checks that have to be applied to the values.\n\n### Required and optional keys\n\nA schema must explicitly define which keys are required and which are optional. This is done by using `required` and `optional` functions. Here's an example:\n\n```elixir\ndefmodule UserContract do\n  use Drops.Contract\n\n  schema do\n    %{\n      optional(:name) =\u003e string(),\n      required(:email) =\u003e string()\n    }\n  end\nend\n\nUserContract.conform(%{email: \"janedoe.org\"})\n# {:ok, %{email: \"janedoe.org\"}}\n\nUserContract.conform(%{name: \"Jane\", email: \"janedoe.org\"})\n# {:ok, %{name: \"Jane\", email: \"janedoe.org\"}}\n```\n\n### Types\n\nYou can define the expected types of the values using `string`, `integer`, `float`, `boolean`, `atom`, `map`, `list`, `any` and `maybe` functions. Here's an example:\n\n```elixir\ndefmodule UserContract do\n  use Drops.Contract\n\n  schema do\n    %{\n      required(:name) =\u003e string(),\n      required(:age) =\u003e integer(),\n      required(:active) =\u003e boolean(),\n      required(:tags) =\u003e list(:string),\n      required(:settings) =\u003e map(:string),\n      required(:address) =\u003e maybe(:string)\n    }\n  end\nend\n```\n\n### Predicate checks\n\nYou can define types that must meet additional requirements by using built-in predicates. Here's an example:\n\n```elixir\ndefmodule UserContract do\n  use Drops.Contract\n\n  schema do\n    %{\n      required(:name) =\u003e string(:filled?),\n      required(:age) =\u003e integer(gt?: 18)\n    }\n  end\nend\n\nUserContract.conform(%{name: \"Jane\", age: 21})\n# {:ok, %{name: \"Jane\", age: 21}}\n\nUserContract.conform(%{name: \"\", age: 21})\n# {:error,\n#  [\n#    %Drops.Validator.Messages.Error.Type{\n#      path: [:name],\n#      text: \"must be filled\",\n#      meta: %{args: [\"\"], predicate: :filled?}\n#    }\n#  ]}\n\nUserContract.conform(%{name: \"Jane\", age: 12})\n# {:error,\n#  [\n#    %Drops.Validator.Messages.Error.Type{\n#      path: [:age],\n#      text: \"must be greater than 18\",\n#      meta: %{args: [18, 12], predicate: :gt?}\n#    }\n#  ]}\n\n```\n\n### Nested schemas\n\nSchemas can be nested, including complex cases like nested lists and maps. Here's an example:\n\n```elixir\ndefmodule UserContract do\n  use Drops.Contract\n\n  schema do\n    %{\n      required(:user) =\u003e %{\n        required(:name) =\u003e string(:filled?),\n        required(:age) =\u003e integer(),\n        required(:address) =\u003e %{\n          required(:city) =\u003e string(:filled?),\n          required(:street) =\u003e string(:filled?),\n          required(:zipcode) =\u003e string(:filled?)\n        },\n        required(:tags) =\u003e\n          list(%{\n            required(:name) =\u003e string(:filled?),\n            required(:created_at) =\u003e integer()\n          })\n      }\n    }\n  end\nend\n\nUserContract.conform(%{\n  user: %{\n    name: \"Jane\",\n    age: 21,\n    address: %{\n      city: \"New York\",\n      street: \"Broadway\",\n      zipcode: \"10001\"\n    },\n    tags: [\n      %{name: \"foo\", created_at: 1_234_567_890},\n      %{name: \"bar\", created_at: 1_234_567_890}\n    ]\n  }\n})\n# {:ok,\n#   %{\n#     user: %{\n#       name: \"Jane\",\n#       address: %{city: \"New York\", street: \"Broadway\", zipcode: \"10001\"},\n#       age: 21,\n#       tags: [\n#         %{name: \"foo\", created_at: 1234567890},\n#         %{name: \"bar\", created_at: 1234567890}\n#       ]\n#     }\n#   }}\n\nUserContract.conform(%{\n  user: %{\n    name: \"Jane\",\n    age: 21,\n    address: %{\n      city: \"New York\",\n      street: \"Broadway\",\n      zipcode: \"\"\n    },\n    tags: [\n      %{name: \"foo\", created_at: 1_234_567_890},\n      %{name: \"bar\", created_at: nil}\n    ]\n  }\n})\n# {:error,\n#  [\n#    %Drops.Validator.Messages.Error.Type{\n#      path: [:user, :address, :zipcode],\n#      text: \"must be filled\",\n#      meta: %{args: [\"\"], predicate: :filled?}\n#    },\n#    %Drops.Validator.Messages.Error.Type{\n#      path: [:user, :tags, 1, :created_at],\n#      text: \"must be an integer\",\n#      meta: %{args: [:integer, nil], predicate: :type?}\n#    }\n#  ]}\n```\n\n### Type-safe casting\n\nYou can define custom type casting functions that will be applied to the input data before it's validated. This is useful when you want to convert the input data to a different format, for example, when you want to convert a string to an integer. Here's an example:\n\n```elixir\ndefmodule UserContract do\n  use Drops.Contract\n\n  schema do\n    %{\n      required(:count) =\u003e cast(:string) |\u003e integer(gt?: 0)\n    }\n  end\nend\n\nUserContract.conform(%{count: \"1\"})\n# {:ok, %{count: 1}}\n\nUserContract.conform(%{count: nil})\n#  [\n#    %Drops.Validator.Messages.Error.Caster{\n#      error: %Drops.Validator.Messages.Error.Type{\n#        path: [:count],\n#        text: \"must be a string\",\n#        meta: %{args: [:string, nil], predicate: :type?}\n#      }\n#    }\n#  ]}\n\nUserContract.conform(%{count: \"-1\"})\n# {:error,\n#  [\n#    %Drops.Validator.Messages.Error.Type{\n#      path: [:count],\n#      text: \"must be greater than 0\",\n#      meta: %{args: [0, -1], predicate: :gt?}\n#    }\n#  ]}\n\n```\n\nIt's also possible to define a custom casting module and use it via `caster` option:\n\n```elixir\ndefmodule CustomCaster do\n  @spec cast(input_type :: atom(), output_type :: atom(), any, Keyword.t()) :: any()\n  def cast(:string, :string, value, _opts) do\n    String.downcase(value)\n  end\nend\n\ndefmodule UserContract do\n  use Drops.Contract\n\n  schema do\n    %{\n      required(:text) =\u003e cast(:string, caster: CustomCaster) |\u003e string()\n    }\n  end\nend\n\nUserContract.conform(%{text: \"HELLO\"})\n# {:ok, %{text: \"hello\"}}\n```\n\n### Atomized maps\n\nYou can define a schema that will atomize the input map using `atomize: true` option. Only keys that you specified will be atomized, any unexpected key will be ignored. Here's an example:\n\n```elixir\ndefmodule UserContract do\n  use Drops.Contract\n\n  schema(atomize: true) do\n    %{\n      required(:name) =\u003e string(),\n      required(:age) =\u003e integer(),\n      required(:tags) =\u003e\n        list(%{\n          required(:name) =\u003e string()\n        })\n    }\n  end\nend\n\nUserContract.conform(%{\n  \"unexpected\" =\u003e \"value\",\n  \"this\" =\u003e \"should not be here\",\n  \"name\" =\u003e \"Jane\",\n  \"age\" =\u003e 21,\n  \"tags\" =\u003e [\n    %{\"name\" =\u003e \"red\"},\n    %{\"name\" =\u003e \"green\"},\n    %{\"name\" =\u003e \"blue\"}\n  ]\n})\n# {:ok,\n#  %{\n#    name: \"Jane\",\n#    age: 21,\n#    tags: [%{name: \"red\"}, %{name: \"green\"}, %{name: \"blue\"}]\n#  }}\n```\n\n## Custom types\n\nIf built-in types are not enough, or if you want to reuse schema definitions, you can define custom types using `Drops.Type`. Here's an example:\n\n```elixir\ndefmodule Types.Age do\n  use Drops.Type, integer(gteq?: 0)\nend\n\ndefmodule Types.Name do\n  use Drops.Type, string(:filled?)\nend\n\ndefmodule UserContract do\n  use Drops.Contract\n\n  schema do\n    %{\n      required(:name) =\u003e Types.Name,\n      required(:age) =\u003e Types.Age\n    }\n  end\nend\n\nUserContract.conform(%{name: \"Jane\", age: 42})\n# {:ok, %{name: \"Jane\", age: 42}}\n\n{:error, errors} = UserContract.conform(%{name: \"Jane\", age: -42})\nEnum.map(errors, \u0026to_string/1)\n# [\"age must be greater than or equal to 0\"]\n\n{:error, errors} = UserContract.conform(%{name: \"Jane\", age: \"42\"})\nEnum.map(errors, \u0026to_string/1)\n# [\"age must be an integer\"]\n```\n\nYou can also define reusable schemas, since they are represented as map type:\n\n```elixir\ndefmodule Types.User do\n  use Drops.Type, %{\n    required(:name) =\u003e string(:filled?),\n    required(:age) =\u003e integer(gteq?: 0)\n  }\nend\n\ndefmodule UserContract do\n  use Drops.Contract\n\n  schema do\n    %{\n      required(:user) =\u003e Types.User\n    }\n  end\nend\n\nUserContract.conform(%{user: %{name: \"Jane\", age: 42}})\n# {:ok, %{user: %{name: \"Jane\", age: 42}}}\n\n{:error, errors} = UserContract.conform(%{user: %{name: \"Jane\", age: -42}})\nEnum.map(errors, \u0026to_string/1)\n# [\"user.age must be greater than or equal to 0\"]\n\n{:error, errors} = UserContract.conform(%{user: %{name: \"Jane\", age: \"42\"}})\nEnum.map(errors, \u0026to_string/1)\n# [\"user.age must be an integer\"]\n```\n\nAnother handy custom type is a union:\n\n```elixir\ndefmodule Types.Price do\n  use Drops.Type, union([:integer, :float], gt?: 0)\nend\n\ndefmodule ProductContract do\n  use Drops.Contract\n\n  schema do\n    %{\n      required(:price) =\u003e Types.Price\n    }\n  end\nend\n\nProductContract.conform(%{price: 42})\n# {:ok, %{price: 42}}\n\nProductContract.conform(%{price: 42.3})\n# {:ok, %{price: 42.3}}\n\n{:error, errors} = ProductContract.conform(%{price: -42})\nEnum.map(errors, \u0026to_string/1)\n# [\"price must be greater than 0\"]\n\n{:error, errors} = ProductContract.conform(%{price: \"42\"})\nEnum.map(errors, \u0026to_string/1)\n# [\"price must be an integer or price must be a float\"]\n```\n\n## Rules\n\nYou can define arbitrary rule functions using `rule` macro. These rules will be applied to the input data only if it passed schema validation. This way you can be sure that rules operate on data that's safe to work with.\n\n\nHere's an example how you could define a rule that checks if either email or login is provided:\n\n\n```elixir\ndefmodule UserContract do\n  use Drops.Contract\n\n  schema do\n    %{\n      required(:email) =\u003e maybe(:string),\n      required(:login) =\u003e maybe(:string)\n    }\n  end\n\n  rule(:either_login_or_email, %{email: nil, login: nil}) do\n    {:error, \"email or login must be provided\"}\n  end\nend\n\nUserContract.conform(%{email: \"jane@doe.org\", login: nil})\n# {:ok, %{email: \"jane@doe.org\", login: nil}}\n\nUserContract.conform(%{email: nil, login: \"jane\"})\n# {:ok, %{email: nil, login: \"jane\"}}\n\nUserContract.conform(%{email: nil, login: nil})\n# {:error,\n#  [\n#    %Drops.Validator.Messages.Error.Rule{\n#      path: [],\n#      text: \"email or login must be present\",\n#      meta: %{}\n#    }\n#  ]}\n```\n","funding_links":[],"categories":["Elixir"],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsolnic%2Fdrops","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsolnic%2Fdrops","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsolnic%2Fdrops/lists"}