lib/avro_ex.ex

defmodule AvroEx do
  @moduledoc """
  AvroEx is a library for encoding and decoding data with Avro schemas.
  Supports parsing schemas, encoding data, and decoding data.

  For encoding and decoding, the following type chart should be referenced:

  | Avro Types | Elixir Types |
  |------------|:------------:|
  | boolean | boolean |
  | integer | integer |
  | long | integer |
  | float | decimal |
  | double | decimal |
  | bytes | binary |
  | string | String.t, atom |
  | null | nil |
  | Record | map |
  | Enum | String.t, atom (corresponding to the enum's symbol list) |
  """
  alias AvroEx.Schema
  alias AvroEx.Schema.Context

  @type encoded_avro :: binary

  @doc """
  Checks to see if the given data is encodable using the given schema. Helpful for unit testing.

      iex> AvroEx.encodable?(%Schema{schema: %Primitive{type: :string}}, "wut")
      true

      iex> AvroEx.encodable?(%Schema{schema: %Primitive{type: :string}}, 12345)
      false
  """
  @spec encodable?(AvroEx.Schema.t(), any) :: boolean
  defdelegate encodable?(schema, data), to: AvroEx.Schema

  @spec parse_schema(Schema.json_schema()) :: {:ok, Schema.t()} | {:error, AvroEx.Schema.DecodeError.t()}
  @deprecated "Use AvroEx.decode_schema/1 instead"
  def parse_schema(json), do: decode_schema(json, [])

  @spec parse_schema!(Schema.json_schema()) :: Schema.t() | no_return
  @deprecated "Use AvroEx.decode_schema!/1 instead"
  def parse_schema!(json), do: decode_schema!(json, [])

  @doc """
  Given an Elixir or JSON-encoded schema, parses the schema and returns a `t:AvroEx.Schema.t/0` struct representing the schema.

  Errors for invalid JSON, invalid schemas, and bad name references.

  ## Options
  * `:strict` - whether to strictly validate the schema, defaults to `false`. Recommended to turn this on for locally owned schemas, but not for interop with external schemas.

  ## Examples

      iex> AvroEx.decode_schema("string")
      {:ok, %AvroEx.Schema{schema: %AvroEx.Schema.Primitive{type: :string}}}

      iex> json= ~S({\"fields\":[{\"name\":\"a\",\"type\":\"string\"}],\"name\":\"my_type\",\"type\":\"record\"})
      iex> {:ok, %Schema{schema: record}} = AvroEx.decode_schema(json)
      iex> match?(%Record{}, record)
      true
  """
  @spec decode_schema(term(), Keyword.t()) :: {:ok, Schema.t()} | {:error, AvroEx.Schema.DecodeError.t()}
  def decode_schema(schema, opts \\ []) do
    try do
      {:ok, decode_schema!(schema, opts)}
    rescue
      error -> {:error, error}
    end
  end

  @doc """
  Same as `AvroEx.decode_schema/1`, but raises an exception on failure instead of
  returning an error tuple.

  ## Examples

      iex> AvroEx.decode_schema!("int")
      %AvroEx.Schema{schema: %AvroEx.Schema.Primitive{type: :int}}

  """
  @spec decode_schema!(term(), Keyword.t()) :: Schema.t()
  def decode_schema!(schema, opts \\ []) do
    if is_binary(schema) and not Schema.Parser.primitive?(schema) do
      schema
      |> Jason.decode!()
      |> Schema.Parser.parse!(opts)
    else
      Schema.Parser.parse!(schema, opts)
    end
  end

  @doc """
  Encodes the given schema to JSON

  ## Options
  * `canonical` - Encodes the schema into its [Parsing Canonical Form](https://avro.apache.org/docs/current/spec.html#Parsing+Canonical+Form+for+Schemas), default `false`

  ## Examples

      iex> schema = AvroEx.decode_schema!(%{"type" => "int", "logicalType" => "date"})
      iex> AvroEx.encode_schema(schema)
      ~S({"type":"int","logicalType":"date"})

      iex> schema = AvroEx.decode_schema!(%{"type" => "int", "logicalType" => "date"})
      iex> AvroEx.encode_schema(schema, canonical: true)
      ~S("int")

  """
  @spec encode_schema(Schema.t(), Keyword.t()) :: String.t()
  def encode_schema(%Schema{} = schema, opts \\ []) do
    AvroEx.Schema.Encoder.encode(schema, opts)
  end

  @doc """
  Given `t:AvroEx.Schema.t/0` and `term()`, takes the data and encodes it according to the schema.

  ## Examples

      iex> schema = AvroEx.decode_schema!("int")
      iex> AvroEx.encode(schema, 1234)
      {:ok, <<164, 19>>}
  """
  @spec encode(Schema.t(), term) ::
          {:ok, encoded_avro} | {:error, AvroEx.EncodeError.t() | Exception.t()}
  def encode(schema, data) do
    AvroEx.Encode.encode(schema, data)
  end

  @doc """
  Same as `encode/2`, but returns the encoded value directly.

  Raises `t:AvroEx.EncodeError.t/0` on error.

  ## Examples

      iex> schema = AvroEx.decode_schema!("boolean")
      iex> AvroEx.encode!(schema, true)
      <<1>>
  """
  @spec encode!(Schema.t(), term()) :: encoded_avro()
  def encode!(schema, data) do
    case AvroEx.Encode.encode(schema, data) do
      {:ok, data} -> data
      {:error, error} -> raise error
    end
  end

  @doc """
  Given an encoded message and its accompanying schema, decodes the message.

      iex> schema = AvroEx.decode_schema!("boolean")
      iex> AvroEx.decode(schema, <<1>>)
      {:ok, true}

  """
  @spec decode(Schema.t(), encoded_avro) ::
          {:ok, term}
          | {:error, AvroEx.DecodeError.t()}
  def decode(schema, message) do
    case AvroEx.Decode.decode(schema, message) do
      {:ok, value, _} -> {:ok, value}
      {:error, error} -> {:error, error}
    end
  end

  @doc """
  Same as decode/2, but returns raw decoded value.

  Raises `t:AvroEx.DecodeError.t/0` on error.

  ## Examples

      iex> schema = AvroEx.decode_schema!("string")
      iex> encoded = AvroEx.encode!(schema, "hello")
      iex> AvroEx.decode!(schema, encoded)
      "hello"
  """
  @spec decode!(Schema.t(), encoded_avro()) :: term()
  def decode!(schema, message) do
    case AvroEx.Decode.decode(schema, message) do
      {:ok, value, _} -> value
      {:error, error} -> raise error
    end
  end

  @deprecated "Use AvroEx.Schema.Context.lookup/2"
  @spec named_type(Schema.full_name(), Schema.t() | Context.t()) :: nil | Schema.schema_types()
  def named_type(name, %Schema{context: %Context{} = context}) when is_binary(name) do
    named_type(name, context)
  end

  def named_type(name, %Context{} = context) do
    Context.lookup(context, name)
  end
end