defmodule Spectral do @moduledoc """ Elixir wrapper for the Erlang `spectra` library. Provides idiomatic Elixir interfaces for encoding, decoding, and schema generation based on type specifications. ## API All functions are designed to work well with Elixir's pipe and with operators: %Person{name: "Alice", age: 30} |> Spectral.encode!(Person, :t) |> send_response() with {:ok, json} <- Spectral.encode(%Person{name: "Alice"}, Person, :t) do send_response(json) end """ @doc """ Encodes data to the specified format. ## Parameters - `data` - The data to encode - `module` - Module containing the type definition - `type_ref` - Type reference (typically an atom like `:t`) - `format` - Format to encode to (default: `:json`) ## Returns - `{:ok, iodata()}` - Encoded data on success - `{:error, [%Spectral.Error{}]}` - List of errors on failure ## Examples iex> person = %Person{name: "Alice", age: 30, address: %Person.Address{street: "Ystader Straße", city: "Berlin"}} ...> with {:ok, json} <- Spectral.encode(person, Person, :t) do ...> IO.iodata_to_binary(json) ...> end ~s({"address":{"city":"Berlin","street":"Ystader Straße"},"age":30,"name":"Alice"}) iex> {:ok, json} = %Person{name: "Alice"} |> Spectral.encode(Person, :t) iex> IO.iodata_to_binary(json) ~s({"name":"Alice"}) """ @spec encode(term(), module(), atom(), atom()) :: {:ok, iodata()} | {:error, [Spectral.Error.t()]} def encode(data, module, type_ref, format \\ :json) do :spectra.encode(format, module, type_ref, data) |> convert_result() rescue error in ErlangError -> handle_erlang_error(error, :encode, module, type_ref) end @doc """ Decodes data from the specified format. ## Parameters - `data` - The data to decode (binary for JSON, string for string format) - `module` - Module containing the type definition - `type_ref` - Type reference (typically an atom like `:t`) - `format` - Format to decode from (default: `:json`) ## Returns - `{:ok, term()}` - Decoded data on success - `{:error, [%Spectral.Error{}]}` - List of errors on failure ## Examples iex> ~s({"name":"Alice","age":30,"address":{"street":"Ystader Straße", "city": "Berlin"}}) ...> |> Spectral.decode(Person, :t) {:ok, %Person{age: 30, name: "Alice", address: %Person.Address{street: "Ystader Straße", city: "Berlin"}}} iex> ~s({"name":"Alice"}) ...> |> Spectral.decode(Person, :t) {:ok, %Person{age: nil, name: "Alice", address: nil}} iex> ~s({"name":"Alice","age":30,"extra_field":"ignored"}) ...> |> Spectral.decode(Person, :t) {:ok, %Person{age: 30, name: "Alice", address: nil}} """ @spec decode(term(), module(), atom(), atom()) :: {:ok, term()} | {:error, [Spectral.Error.t()]} def decode(data, module, type_ref, format \\ :json) do :spectra.decode(format, module, type_ref, data) |> convert_result() rescue error in ErlangError -> handle_erlang_error(error, :decode, module, type_ref) end @doc """ Generates a schema for the specified type. ## Parameters - `module` - Module containing the type definition - `type_ref` - Type reference (typically an atom like `:t`) - `format` - Schema format (default: `:json_schema`) ## Returns - `{:ok, iodata()}` - Generated schema on success - `{:error, [%Spectral.Error{}]}` - List of errors on failure ## Examples iex> {:ok, schemadata} = Spectral.schema(Person, :t) iex> is_binary(IO.iodata_to_binary(schemadata)) true """ @spec schema(module(), atom(), atom()) :: {:ok, iodata()} | {:error, [Spectral.Error.t()]} def schema(module, type_ref, format \\ :json_schema) do :spectra.schema(format, module, type_ref) |> convert_result() rescue error in ErlangError -> handle_erlang_error(error, :schema, module, type_ref) end @doc """ Encodes data to the specified format, raising on error. Like `encode/4` but raises `Spectral.Error` instead of returning an error tuple. ## Parameters - `data` - The data to encode - `module` - Module containing the type definition - `type_ref` - Type reference (typically an atom like `:t`) - `format` - Format to encode to (default: `:json`) ## Returns - `iodata()` - Encoded data on success ## Raises - `Spectral.Error` - If encoding fails ## Examples iex> %Person{name: "Alice", age: 30} ...> |> Spectral.encode!(Person, :t) ...> |> IO.iodata_to_binary() ~s({"age":30,"name":"Alice"}) """ @spec encode!(term(), module(), atom(), atom()) :: iodata() def encode!(data, module, type_ref, format \\ :json) do case encode(data, module, type_ref, format) do {:ok, result} -> result {:error, [error | _]} -> raise Spectral.Error.exception(error) end end @doc """ Decodes data from the specified format, raising on error. Like `decode/4` but raises `Spectral.Error` instead of returning an error tuple. ## Parameters - `data` - The data to decode (binary for JSON, string for string format) - `module` - Module containing the type definition - `type_ref` - Type reference (typically an atom like `:t`) - `format` - Format to decode from (default: `:json`) ## Returns - `term()` - Decoded data on success ## Raises - `Spectral.Error` - If decoding fails ## Examples iex> ~s({"name":"Alice","age":30}) ...> |> Spectral.decode!(Person, :t) %Person{age: 30, name: "Alice", address: nil} """ @spec decode!(term(), module(), atom(), atom()) :: term() def decode!(data, module, type_ref, format \\ :json) do case decode(data, module, type_ref, format) do {:ok, result} -> result {:error, [error | _]} -> raise Spectral.Error.exception(error) end end @doc """ Generates a schema for the specified type, raising on error. Like `schema/3` but raises `Spectral.Error` instead of returning an error tuple. ## Parameters - `module` - Module containing the type definition - `type_ref` - Type reference (typically an atom like `:t`) - `format` - Schema format (default: `:json_schema`) ## Returns - `iodata()` - Generated schema on success ## Raises - `Spectral.Error` - If schema generation fails ## Examples iex> schemadata = Spectral.schema!(Person.Address, :t) iex> IO.iodata_to_binary(schemadata) ~s({"type":"object","required":["street","city"],"additionalProperties":false,"properties":{"city":{"type":"string"},"street":{"type":"string"}}}) """ @spec schema!(module(), atom(), atom()) :: iodata() def schema!(module, type_ref, format \\ :json_schema) do case schema(module, type_ref, format) do {:ok, result} -> result {:error, [error | _]} -> # Call exception/1 to populate the message field raise Spectral.Error.exception(error) end end # Private helper to convert Erlang results to Elixir defp convert_result({:ok, result}), do: {:ok, result} defp convert_result({:error, erlang_errors}) when is_list(erlang_errors) do {:error, Spectral.Error.from_erlang_list(erlang_errors)} end # Handles Erlang errors from the spectra library and converts configuration # errors to idiomatic Elixir ArgumentErrors defp handle_erlang_error(%ErlangError{original: original} = error, operation, module, type_ref) do case original do {:module_types_not_found, ^module, _reason} -> raise ArgumentError, "module #{inspect(module)} not found, not loaded, or not compiled with debug_info (#{operation})" {:type_or_record_not_found, ^type_ref} -> raise ArgumentError, "type #{inspect(type_ref)} not found in module #{inspect(module)} (#{operation})" {:type_not_supported, type_info} -> raise ArgumentError, "type not supported: #{inspect(type_info)} (#{operation})" _other -> # Re-raise the original ErlangError if it's not a known configuration error raise error end end end