Estructura    Kantox ❤ OSS  Test  Dialyzer

Copy Markdown View Source

Extensions for Elixir structures.

Installation

def deps do
  [
    {:estructura, "~> 1.14"}
  ]
end

Features

Nested Structures

Estructura.Nested provides powerful nested structure support with validation, coercion, and generation capabilities:

defmodule User do
  use Estructura.Nested

  defstruct [
    name: "",
    address: %{
      city: "",
      street: %{name: "", house: 0}
    }
  ]

  # Validation rules
  def validate(:name, value), do: String.length(value) > 0
  def validate("address.street.house", value), do: value > 0
end

# Usage
iex> user = %User{name: "John", address: %{city: "London", street: %{name: "High St", house: 42}}}
iex> User.validate(user)
{:ok, %User{...}}

Type System

Estructura provides a rich type system with built-in types and scaffolds for custom types:

Built-in Types

  • DateTime - For handling datetime values
  • Date - For date values
  • Time - For time values
  • URI - For URI handling
  • IP - For IPv4 and IPv6 addresses
  • String - For string values
defmodule Event do
  use Estructura.Nested

  defstruct [
    timestamp: nil,
    url: nil
  ]

  def type(:timestamp), do: Estructura.Nested.Type.DateTime
  def type(:url), do: Estructura.Nested.Type.URI
end

Type Scaffolds

Enum Types

Create types with predefined values:

defmodule Status do
  use Estructura.Nested.Type.Enum,
    elements: [:pending, :active, :completed]
end

iex> Status.validate(:pending)
{:ok, :pending}
iex> Status.validate(:invalid)
{:error, "Expected :invalid to be one of: [:pending, :active, :completed]"}
Tag Sets

Manage lists of predefined tags:

defmodule Categories do
  use Estructura.Nested.Type.Tags,
    elements: [:tech, :art, :science]
end

iex> Categories.validate([:tech, :art])
{:ok, [:tech, :art]}
iex> Categories.validate([:invalid])
{:error, "All tags are expected to be one of [:tech, :art, :science]..."}

JSON Schema Support

Estructura.Nested can derive nested structures directly from JSON Schema definitions using the json_schema/1 macro:

defmodule ApiResponse do
  use Estructura.Nested

  json_schema %{
    "type" => "object",
    "properties" => %{
      "id" => %{"type" => "integer", "minimum" => 1},
      "name" => %{"type" => "string", "default" => "anonymous"},
      "created_at" => %{"type" => "string", "format" => "date-time"},
      "address" => %{
        "type" => "object",
        "properties" => %{
          "city" => %{"type" => "string"},
          "zip" => %{"type" => "string"}
        }
      },
      "tags" => %{"type" => "array", "items" => %{"type" => "string"}},
      "status" => %{"type" => "string", "enum" => ["active", "inactive"]}
    },
    "required" => ["id", "name"]
  }
end

iex> ApiResponse.cast(%{id: 1, name: "Alice", address: %{city: "Barcelona", zip: "08001"}})
{:ok, %ApiResponse{id: 1, name: "Alice", address: %ApiResponse.Address{city: "Barcelona", zip: "08001"}, ...}}

You can also load a schema from a file:

json_schema File.read!("priv/schemas/response.json")

JSON Schema types and formats are automatically mapped to Estructura types (e.g. "date-time" -> :datetime, "uri" -> Estructura.Nested.Type.URI, "enum" -> Estructura.Nested.Type.Enum). Features include $ref resolution, allOf merging, nullable types, and default value extraction. See Estructura.Nested.JsonSchema for the full type mapping reference.

With Indifferent Access

Inspired by Ruby's Hash#with_indifferent_access, Estructura.WIA provides struct-like containers accessible by both atom and binary keys:

defmodule Config do
  use Estructura.WIA,
    fields: [
      host: [default: "localhost"],
      port: [default: 4000, coerce: true, validate: true]
    ]

  @impl Config.Coercible
  def coerce_port(value) when is_integer(value), do: {:ok, value}
  def coerce_port(value) when is_binary(value) do
    case Integer.parse(value) do
      {int, ""} -> {:ok, int}
      _ -> {:error, "invalid port"}
    end
  end

  @impl Config.Validatable
  def validate_port(port) when port in 1..65535, do: {:ok, port}
  def validate_port(port), do: {:error, "port out of range: #{port}"}
end

iex> config = %Config{}
iex> config[:port]
4000
iex> config["port"]
4000
iex> put_in(config, ["port"], "8080")
%Config{host: "localhost", port: 8080}

Enumerable, Collectable, Inspect, and Jason.Encoder protocols are implemented automatically to mimic map behaviour.

The underlying indifferent: true option is also available directly via use Estructura for custom struct definitions.

Coercion and Validation

Estructura provides flexible coercion and validation:

defmodule Temperature do
  use Estructura.Nested

  defstruct value: 0, unit: :celsius

  def coerce(:value, str) when is_binary(str) do
    case Float.parse(str) do
      {num, ""} -> {:ok, num}
      _ -> {:error, "Invalid number"}
    end
  end

  def validate(:value, v), do: v >= -273.15  # Absolute zero
  def validate(:unit, u), do: u in [:celsius, :fahrenheit, :kelvin]
end

Lazy Values

Use Estructura.Lazy for deferred computation:

defmodule Cache do
  use Estructura.Nested

  defstruct value: Estructura.Lazy.new(&expensive_computation/1)

  def expensive_computation(_), do: :timer.sleep(1000) && :computed
end

Flattening and Transformation

Convert nested structures to flat representations:

defmodule User do
  use Estructura.Nested, flattenable: true

  defstruct name: "", address: %{city: "", postal_code: ""}
end

iex> user = %User{name: "John", address: %{city: "London", postal_code: "SW1"}}
iex> Estructura.Flattenable.flatten(user)
%{"name" => "John", "address_city" => "London", "address_postal_code" => "SW1"}

Property Testing

Estructura supports property-based testing out of the box:

defmodule UserTest do
  use ExUnit.Case
  use ExUnitProperties

  property "valid users are validated" do
    check all %User{} = user <- User.__generator__() do
      assert {:ok, ^user} = User.validate(user)
    end
  end
end

Changelog

Documentation