Skip to main content

lib/uof/schemas/xml.ex

defmodule UOF.Schemas.XML do
  @moduledoc """
  Generic XML -> Ecto embedded schema decoder.

  Parses an XML document with `Saxy` and, driven purely by the target schema's
  Ecto reflection, builds a (possibly deeply nested) params map that is cast
  through the schema's changeset. Because the generated schemas mirror the XSD
  nesting, every XML element/attribute name lines up with a schema field name,
  so one recursive walk handles all of them.

  Call `decode/1` to dispatch on the document's root element against the full
  generated registry (`UOF.Schemas.XML.Registry`) — every feed message and API
  response decodes through this one entry point. Use `decode/2` only when you
  need to override that: pass a schema module to force a known type, or a custom
  `root element => module` registry to scope dispatch.
  """

  @doc """
  Decode `xml`, dispatching on its root element against the full schema registry.

  This is the everyday entry point: it handles every known feed message and API
  response. Returns `{:ok, struct}`, `{:error, Ecto.Changeset.t()}`, or
  `{:error, {:unknown_message, name}}` for an unrecognised root element.
  """
  def decode(xml) when is_binary(xml), do: decode(xml, UOF.Schemas.XML.Registry.all())

  @doc """
  Decode `xml` against an explicit `target`, overriding the default registry.

  `target` selects the schema:

    * a **schema module** — decode into that type (static). Use when the caller
      already knows it and wants to assert it regardless of the root element.
    * a **registry map** of `root element name => schema module` — dispatch on
      the document's root element, scoped to that map.

  Returns `{:ok, struct}` or `{:error, Ecto.Changeset.t()}`; with a registry
  that has no entry for the root element, `{:error, {:unknown_message, name}}`.
  """
  def decode(xml, target) when is_binary(xml) do
    {:ok, {tag, _attrs, _children} = root} = Saxy.SimpleForm.parse_string(xml)
    decode_root(root, tag, target)
  end

  # static: an explicit schema module.
  defp decode_root(root, _tag, module) when is_atom(module), do: to_struct(root, module)

  # dynamic: a `root element => module` registry.
  defp decode_root(root, tag, registry) when is_map(registry) do
    case Map.fetch(registry, local(tag)) do
      {:ok, module} -> to_struct(root, module)
      :error -> {:error, {:unknown_message, local(tag)}}
    end
  end

  defp to_struct(root, module) do
    module
    |> struct()
    |> module.changeset(to_params(root, module))
    |> Ecto.Changeset.apply_action(:insert)
  end

  # Build a params map for `module` from a `{tag, attributes, children}` node.
  defp to_params({_tag, attributes, children}, module) do
    child_elements = Enum.filter(children, &is_tuple/1)
    embeds = module.__schema__(:embeds)
    scalar_fields = module.__schema__(:fields) -- embeds

    attributes
    |> Map.new(fn {name, value} -> {local(name), value} end)
    |> put_scalar_elements(scalar_fields, child_elements)
    |> put_embeds(embeds, child_elements, module)
  end

  # Scalar fields may also arrive as text-bearing child elements (e.g.
  # <message>...</message>); attributes already present win.
  defp put_scalar_elements(params, scalar_fields, child_elements) do
    Enum.reduce(scalar_fields, params, fn field, acc ->
      name = Atom.to_string(field)

      cond do
        Map.has_key?(acc, name) -> acc
        element = find(child_elements, name) -> Map.put(acc, name, text(element))
        true -> acc
      end
    end)
  end

  defp put_embeds(params, embeds, child_elements, module) do
    Enum.reduce(embeds, params, fn embed, acc ->
      %Ecto.Embedded{related: related, cardinality: cardinality} =
        module.__schema__(:embed, embed)

      name = Atom.to_string(embed)
      matches = filter(child_elements, name)

      value =
        case cardinality do
          :one -> matches |> List.first() |> maybe_to_params(related)
          :many -> Enum.map(matches, &to_params(&1, related))
        end

      if value in [nil, []], do: acc, else: Map.put(acc, name, value)
    end)
  end

  defp maybe_to_params(nil, _module), do: nil
  defp maybe_to_params(element, module), do: to_params(element, module)

  defp find(elements, name), do: Enum.find(elements, &named?(&1, name))
  defp filter(elements, name), do: Enum.filter(elements, &named?(&1, name))
  defp named?({tag, _a, _c}, name), do: local(tag) == name

  defp text({_tag, _attrs, children}) do
    children |> Enum.filter(&is_binary/1) |> Enum.join() |> String.trim()
  end

  # Drop any namespace prefix ("ns:market" -> "market").
  defp local(name) do
    case String.split(name, ":", parts: 2) do
      [_prefix, local] -> local
      [local] -> local
    end
  end
end