Skip to main content

lib/astral/schema.ex

defmodule Astral.Schema do
  @moduledoc """
  Unified schema adapter for Astral content metadata.

  JSON Schema maps, including maps produced by `JSONSpec.schema/2`, are the
  preferred collection schema format. Zoi schemas are also supported.
  """

  @type schema_type :: :json_schema | :zoi | :fields | :empty | :unknown

  @doc "Detect the supported schema backend."
  @spec schema_type(term()) :: schema_type()
  def schema_type(nil), do: :empty

  def schema_type(%Astral.Schema.Fields{}), do: :fields

  def schema_type(%{"type" => "object", "properties" => props}) when is_map(props),
    do: :json_schema

  def schema_type(schema) do
    if zoi_schema?(schema), do: :zoi, else: :unknown
  end

  @doc "Validate and normalize metadata according to the schema."
  @spec normalize(term(), map(), keyword()) :: {:ok, map()} | {:error, term()}
  def normalize(schema, metadata, opts \\ [])

  def normalize(nil, _metadata, _opts), do: {:ok, %{}}

  def normalize(schema, metadata, opts) do
    case schema_type(schema) do
      :json_schema -> normalize_json_schema(schema, metadata)
      :zoi -> normalize_zoi(schema, metadata)
      :fields -> Astral.Schema.Fields.normalize(schema, metadata, opts)
      :unknown -> {:error, {:unsupported_schema, schema}}
    end
  end

  @doc "Convert a supported schema to JSON Schema when possible."
  @spec to_json_schema(term()) :: map() | nil
  def to_json_schema(nil), do: nil
  def to_json_schema(%Astral.Schema.Fields{}), do: nil
  def to_json_schema(%{"type" => "object", "properties" => _} = schema), do: schema

  def to_json_schema(schema) do
    if zoi_schema?(schema), do: Zoi.to_json_schema(schema)
  end

  defp normalize_json_schema(schema, metadata) do
    with {:ok, validated} <- validate_json_schema(schema, metadata) do
      {:ok, JSONSpec.atomize(schema, validated)}
    end
  end

  defp validate_json_schema(schema, metadata) do
    schema
    |> JSV.build!()
    |> then(&JSV.validate(metadata, &1))
    |> case do
      {:ok, validated} -> {:ok, validated}
      {:error, error} -> {:error, {:invalid_metadata, error}}
    end
  end

  defp normalize_zoi(schema, metadata) do
    case Zoi.parse(schema, metadata, coerce: true) do
      {:ok, data} -> {:ok, data}
      {:error, errors} -> {:error, {:invalid_metadata, errors}}
    end
  end

  defp zoi_schema?(schema) do
    Code.ensure_loaded?(Zoi) and function_exported?(Zoi, :to_json_schema, 1) and
      zoi_to_json_schema?(schema)
  end

  defp zoi_to_json_schema?(schema) do
    Zoi.to_json_schema(schema)
    true
  rescue
    _error in [Protocol.UndefinedError, FunctionClauseError, ArgumentError] -> false
  end
end