Zoi Schema Validation for Elixir

repository·main·Indexed 42 days ago

https://github.com/phcurado/zoi

A flexible, composable schema validation library for Elixir inspired by Zod and Ecto.Changeset. Zoi provides tools for data transformation, JSON Schema interoperability, and seamless integration with Phoenix forms. It supports a wide range of types including discriminated unions, intersections, and custom refinements, and includes built-in support for Gettext error localization and Elixir type specification generation via Zoi.type_spec/1.

Tokens
14.9K
Snippets
47
Records
52
Agent score
49%

What's inside Zoi

  1. Generalize and reuse Zoi types

    main

    Since Zoi types are just functions, you can create reusable type definitions by wrapping them in standard Elixir modules. This allows you to maintain a single source of truth for common fields (like user info or currency codes) across multiple schemas.

    defmodule MyApp.ZoiTypes do
      def user_info() do
        Zoi.map(%{
          name: Zoi.string(description: "user full name"),
          email: Zoi.email(description: "user email address")
        })
      end
    end
    
    # Reuse in another schema
    schema = Zoi.map(%{
      user: MyApp.ZoiTypes.user_info()
    })
  2. Understand error reporting with transformed keys

    main

    When using Zoi.transform/2 to change keys, Zoi maintains a link to the original input keys for error reporting. If validation fails, the Zoi.Error struct will contain the original key in the path and issue fields, making it easier to map errors back to the raw input data.

    # If a required key is missing from the input
    schema = MyApp.User.schema()
    Zoi.parse(schema, %{"@name" => "John"})
    #=> {:error, [
    #     %Zoi.Error{
    #       code: :required,
    #       issue: {"is required", [key: "__last_name__"]},
    #       message: "is required",
    #       path: ["__last_name__"]
    #     }
    #   ]}
  3. Generate Zoi schemas from JSON examples

    main

    If you need to integrate with an external API and want to avoid manually writing schemas, you can infer them from a sample JSON response. By recursively traversing a decoded JSON structure, you can build an Elixir AST that maps JSON types to Zoi schema functions.

    Type Mapping Logic

    The following mapping is used to translate JSON values into Zoi schema calls:

    • Map $\rightarrow$ Zoi.map(%{...})
    • List $\rightarrow$ Zoi.array(inner_type) (uses the first element to infer inner_type; empty lists default to Zoi.any())
    • String $\rightarrow$ Zoi.string()
    • Integer $\rightarrow$ Zoi.integer()
    • Float $\rightarrow$ Zoi.number()
    • Boolean $\rightarrow$ Zoi.boolean()
    • Other $\rightarrow$ Zoi.optional(Zoi.any())
    defmodule ZoiJsonCodegen do
      def write_module_from_json(json, module_name) do
        body = code_from_json(json)
        mod_file = Path.join("lib", Macro.underscore("#{module_name}") <> ".ex")
    
        source = """
        defmodule #{module_name} do
          @moduledoc false
    
          def #{:schema}() do
            #{body}
          end
        end
        """
    
        formatted = Code.format_string!(source) |> IO.iodata_to_binary()
        File.write!(mod_file, formatted)
        mod_file
      end
    
      def code_from_json(json) when is_binary(json) do
        json
        |> Jason.decode!()
        |> build_schema_ast()
        |> ast_to_string()
      end
    
      defp ast_to_string(ast) do
        ast
        |> Macro.to_string()
        |> Code.format_string!()
        |> IO.iodata_to_binary()
      end
    
      defp build_schema_ast(v) when is_map(v) do
        map_ast = {
          :%{}, 
          [],
          Enum.map(v, fn {k, vv} -> {k, build_schema_ast(vv)} end)
        }
        quote(do: Zoi.map(unquote(map_ast)))
      end
    
      defp build_schema_ast(v) when is_list(v) do
        inner = case v do
          [h | _] -> build_schema_ast(h)
          [] -> quote(do: Zoi.any())
        end
        quote(do: Zoi.array(unquote(inner)))
      end
    
      defp build_schema_ast(v) when is_binary(v), do: quote(do: Zoi.string())
      defp build_schema_ast(v) when is_integer(v), do: quote(do: Zoi.integer())
      defp build_schema_ast(v) when is_float(v), do: quote(do: Zoi.number())
      defp build_schema_ast(v) when is_boolean(v), do: quote(do: Zoi.boolean())
      defp build_schema_ast(_), do: quote(do: Zoi.optional(Zoi.any()))
    end
  4. Integrate Zoi with Phoenix and Oaskit for OpenAPI generation

    main

    To generate OpenAPI specifications for a Phoenix application, you can combine Zoi with the Oaskit library.

    1. Define your Zoi schema as a module attribute.
    2. Convert that schema to JSON Schema using Zoi.to_json_schema/1 and store it in another attribute.
    3. Use the resulting JSON Schema within Oaskit's operation macro to define request_body and responses.

    This approach provides both automated OpenAPI documentation and a schema that can be used for runtime parameter validation.

    defmodule MyAppWeb.UserController do
      use MyAppWeb, :controller
      use Oaskit.Controller
    
      @user_schema Zoi.map(%{
        id: Zoi.integer() |> Zoi.min(1),
        name: Zoi.string() |> Zoi.min(1) |> Zoi.max(100),
        age: Zoi.optional(Zoi.integer() |> Zoi.min(0))
      })
    
      @user_spec Zoi.to_json_schema(@user_schema)
    
      operation :create,
        summary: "Create User",
        request_body: {@user_spec, [required: true]},
        responses: [ok: {@user_spec, []}]
    
        def create(conn, params) do
          # Validate params using Zoi or Oaskit
        end
    end
  5. Validate controller request parameters with Zoi

    main

    Use Zoi to validate and sanitize incoming request parameters in a Phoenix controller before they reach your business logic. This is useful when your API schema differs from your database schema, when you need custom validation rules, or when you want to fail early with structured error messages.

    To implement this:

    1. Define a schema using Zoi.map/2 as a module attribute.
    2. Use Zoi.parse/2 to validate the incoming params.
    3. Handle the {:ok, valid_params} case to proceed to your domain logic.
    4. Handle the {:error, errors} case by returning an error response, typically using Zoi.treefy_errors/1 to format the errors into a structured tree suitable for JSON APIs.
    defmodule MyAppWeb.UserController do
      use MyAppWeb, :controller
    
      alias MyApp.Users
    
      @user_params Zoi.map(%{
        name: Zoi.string(),
        email: Zoi.email() |> Zoi.min(4) |> Zoi.max(100),
        age: Zoi.integer(coerce: true) |> Zoi.min(18) |> Zoi.max(100)
      }, coerce: true)
    
      def create(conn, params) do
        case Zoi.parse(@user_params, params) do
          {:ok, valid_params} ->
            case Users.create_user(valid_params) do
              {:ok, user} ->
                conn
                |> put_status(:created)
                |> render("show.json", user: user)
    
              {:error, changeset} ->
                conn
                |> put_status(:unprocessable_entity)
                |> render(MyAppWeb.ChangesetView, "error.json", changeset: changeset)
            end
    
          {:error, errors} ->
            conn
            |> put_status(:unprocessable_entity)
            |> render(MyAppWeb.ErrorView, "error.json", errors: Zoi.treefy_errors(errors))
        end
      end
    end
  6. Localize Zoi errors with Gettext

    main

    Zoi errors (Zoi.Error) store two versions of every message: a rendered message string and an issue tuple containing {template, keyword}. To support localization, you should use the issue tuple, which preserves interpolation markers (like %{count}) that Gettext can use for translations.

    When defining custom errors in Zoi.refine/2, return the issue tuple instead of a pre-rendered string to allow for dynamic translation of values.

    schema =
      Zoi.string()
      |> Zoi.refine(fn value ->
        if String.length(value) < 3 do
          {:error, {"too short, should be smaller than %{count}", [count: 3]}}
        else
          :ok
        end
      end)
  7. Work with nested data structures and dynamic arrays

    main

    Nested Schemas

    Use Zoi.array/1 containing a Zoi.map/1 to define nested structures. In your template, use <.inputs_for> to iterate over the collection.

    Dynamic Add/Remove

    To add or remove items from an array, manipulate socket.assigns.form.params directly. Because Zoi.Form.parse/2 automatically converts LiveView's map-based array format into standard Elixir lists, you can use List.delete_at/2 or ++ to modify the data before re-parsing.

    # Schema definition
    @user_schema Zoi.map(%{
      addresses: Zoi.array(
        Zoi.map(%{
          street: Zoi.string(),
          city: Zoi.string()
        })
      )
    }) |> Zoi.Form.prepare()
    
    # Dynamic Remove Example
    def handle_event("remove_address", %{"index" => index}, socket) do
      addresses = socket.assigns.form.params["addresses"] || []
      idx = String.to_integer(index)
      
      updated_params = Map.put(socket.assigns.form.params, "addresses", List.delete_at(addresses, idx))
      ctx = Zoi.Form.parse(@user_schema, updated_params)
      {:noreply, assign(socket, form: to_form(ctx, as: :user))}
    end
  8. Extract and merge Zoi error translations

    main

    After adding the Zoi error templates to your priv/gettext/errors.pot file, run the following command to propagate the entries into your specific locale files (e.g., priv/gettext/pt_BR/LC_MESSAGES/errors.po):

    mix gettext.extract --merge

    You can then manually edit the .po files to provide the translations.

  9. Apply coercion or nullability globally using `Zoi.Schema.traverse/2`

    main

    Instead of manually adding options like coerce: true, nullable(), or optional() to every field in a large schema, you can use Zoi.Schema.traverse/2 to apply these transformations to every node in the schema tree.

    To apply coercion to all fields:

    schema = Zoi.map(%{...
    }) |> Zoi.Schema.traverse(&Zoi.coerce/1)

    To apply multiple transformations (like making everything nullable and optional):

    schema = Zoi.map(%{...
    }) |> Zoi.Schema.traverse(fn node ->
      node
      |> Zoi.nullable()
      |> Zoi.optional()
    end)
    schema = Zoi.map(%{
      name: Zoi.string(),
      age: Zoi.integer(),
      address: Zoi.map(%{
        street: Zoi.string(),
        city: Zoi.string()
      })
    }) |> Zoi.Schema.traverse(&Zoi.coerce/1)
  10. Add external errors to a Zoi context

    main

    If your backend (e.g., Ecto or business logic) returns errors that Zoi didn't catch during parsing, you can manually inject them into the context using Zoi.Context.add_error/2.

    Custom Errors

    Use Zoi.Error.custom_error/2 to create an error with a specific issue (a {message, options} tuple) and a path (a list of keys pointing to the field).

    Ecto Changeset Errors

    To convert Ecto changeset errors to Zoi errors, iterate over changeset.errors and map them using Zoi.Error.custom_error/2 with the field as the path.

    # Adding a manual error
    error = Zoi.Error.custom_error(issue: {"has already been taken", []}, path: [:email])
    ctx = Zoi.Context.add_error(ctx, error)
    
    # Mapping Ecto errors
    defp add_changeset_errors(changeset, ctx) do
      Enum.reduce(changeset.errors, ctx, fn {field, {msg, opts}}, acc ->
        error = Zoi.Error.custom_error(issue: {msg, opts}, path: [field])
        Zoi.Context.add_error(acc, error)
      end)
    end
  11. Parse and render Zoi forms in Phoenix LiveView

    main

    Use Zoi.Form.parse/2 to parse incoming parameters against your prepared schema. To integrate with Phoenix's form components, convert the resulting context into a Phoenix form using to_form(ctx, as: :name).

    In your render/1 function, use the <.form> component with the for attribute set to your assigned form. Phoenix's <.input> components will automatically display validation errors from the Zoi context.

    # In mount/3
    def mount(_params, _session, socket) do
      params = %{}  # Start with empty form
      ctx = Zoi.Form.parse(@user_schema, params)
      {:ok, assign(socket, to_form(ctx, as: :user))}
    end
    
    # In render/1
    ~H"""
    <.form for={@form} phx-change="validate" phx-submit="save">
      <.input field={@form[:name]} label="Name" />
      <.input field={@form[:email]} label="Email" />
      <.input field={@form[:age]} type="number" label="Age" />
      <div class=""><.button>Save</.</.button></div>
    </.form>
    """