Current section
Files
Jump to
Current section
Files
lib/cfdi/xml/element.ex
defmodule Cfdi.Xml.Element do
@moduledoc """
Declarative XML element schema — Ecto-style DSL for CFDI elements.
Define an element's attributes, namespaces, and XML tag in one module.
The macro generates:
* `defstruct` with all declared fields (default `nil`)
* `@type t` with attribute types
* `to_element/1` (and `to_element/2` for elements that accept children)
that serializes to an `XmlBuilder` tuple
* `__xml__/1` introspection helpers (`:tag`, `:attributes`, `:children`,
`:namespaces`)
## Example
defmodule Cfdi.Emisor do
use Cfdi.Xml.Element, tag: "cfdi:Emisor"
attribute :Rfc, :string
attribute :Nombre, :string
attribute :RegimenFiscal, :string
attribute :FacAtrAdquirente, :string
end
Now you can do:
%Cfdi.Emisor{Rfc: "AAA010101AAA", Nombre: "ACME"}
|> Cfdi.Emisor.to_element()
#=> {"cfdi:Emisor", %{"Rfc" => "AAA010101AAA", "Nombre" => "ACME"}, nil}
## Namespaces (root element)
defmodule Cfdi.Comprobante do
use Cfdi.Xml.Element, tag: "cfdi:Comprobante", accepts_children: true
xmlns :cfdi, "http://www.sat.gob.mx/cfd/4"
xmlns :xsi, "http://www.w3.org/2001/XMLSchema-instance"
attribute :Version, :string
# ...
end
When `accepts_children: true` is set, `to_element/2` is defined with a
second argument for nested XML tuples.
## Children fields (not serialized as attributes)
Some CFDI elements have fields that represent nested structures rather
than XML attributes (e.g. `Concepto` holds a list of `impuestos`). Use
`child` instead of `attribute`:
defmodule Cfdi.Concepto do
use Cfdi.Xml.Element, tag: "cfdi:Concepto"
attribute :ClaveProdServ, :string
# ...
child :impuestos, :list
child :informacion_aduanera, :list
end
Children appear in the struct and `@type t` but are not serialized to
XML attributes in the auto-generated `to_element/1` — callers render
them explicitly.
"""
@doc false
defmacro __using__(opts) do
tag = Keyword.fetch!(opts, :tag)
accepts_children = Keyword.get(opts, :accepts_children, false)
quote do
import Cfdi.Xml.Element, only: [attribute: 2, attribute: 3, child: 2, xmlns: 2]
Module.register_attribute(__MODULE__, :xml_attributes, accumulate: true)
Module.register_attribute(__MODULE__, :xml_children, accumulate: true)
Module.register_attribute(__MODULE__, :xml_namespaces, accumulate: true)
@xml_tag unquote(tag)
@xml_accepts_children unquote(accepts_children)
@before_compile Cfdi.Xml.Element
end
end
@doc """
Declares an XML attribute on the element.
"""
defmacro attribute(name, type, _opts \\ []) do
quote do
@xml_attributes {unquote(name), unquote(type)}
end
end
@doc """
Declares a nested-structure field that is NOT serialized as an attribute.
"""
defmacro child(name, type) do
quote do
@xml_children {unquote(name), unquote(type)}
end
end
@doc """
Declares a namespace (xmlns:prefix="uri") for the element. Typically used
on root elements only.
"""
defmacro xmlns(prefix, uri) do
quote do
@xml_namespaces {unquote(prefix), unquote(uri)}
end
end
@doc false
defmacro __before_compile__(env) do
attrs = Module.get_attribute(env.module, :xml_attributes) |> Enum.reverse()
children = Module.get_attribute(env.module, :xml_children) |> Enum.reverse()
namespaces = Module.get_attribute(env.module, :xml_namespaces) |> Enum.reverse()
accepts_children = Module.get_attribute(env.module, :xml_accepts_children)
attr_names = Enum.map(attrs, &elem(&1, 0))
child_names = Enum.map(children, &elem(&1, 0))
struct_fields = attr_names ++ child_names
type_spec =
(attrs ++ children)
|> Enum.map(fn {name, type} -> {name, type_to_spec(type)} end)
to_element_arity =
if accepts_children do
quote do
@spec to_element(t()) :: tuple()
def to_element(%__MODULE__{} = el), do: to_element(el, [])
@spec to_element(t(), iodata()) :: tuple()
def to_element(%__MODULE__{} = el, kids) do
XmlBuilder.element(
{@xml_tag, Cfdi.Xml.Element.__build_attrs__(el, __MODULE__), List.wrap(kids)}
)
end
end
else
quote do
@spec to_element(t() | nil) :: tuple() | nil
def to_element(nil), do: nil
def to_element(%__MODULE__{} = el) do
XmlBuilder.element({@xml_tag, Cfdi.Xml.Element.__build_attrs__(el, __MODULE__), nil})
end
end
end
quote do
defstruct unquote(struct_fields)
@type t :: %__MODULE__{unquote_splicing(type_spec)}
def __xml__(:tag), do: @xml_tag
def __xml__(:attributes), do: unquote(attr_names)
def __xml__(:children), do: unquote(child_names)
def __xml__(:namespaces), do: unquote(Macro.escape(namespaces))
def __xml__(:accepts_children), do: @xml_accepts_children
@doc "Tag XML completo (con namespace, si lo hay). Ej: `\"cfdi:Emisor\"`."
@spec tag() :: String.t()
def tag(), do: @xml_tag
@doc "Prefijo de namespace del tag (`\"cfdi\"`) o `nil` si no lleva."
@spec namespace() :: String.t() | nil
def namespace(), do: Cfdi.Xml.Element.__split_tag__(@xml_tag) |> elem(0)
@doc "Nombre local del tag, sin namespace. Ej: `\"Emisor\"`."
@spec local_name() :: String.t()
def local_name(), do: Cfdi.Xml.Element.__split_tag__(@xml_tag) |> elem(1)
@doc """
Proyecta la struct a mapa.
Opciones:
* `:ns` — `true` (default) incluye prefijo y envuelve bajo `"cfdi:Tag"`;
`false` devuelve solo los atributos como mapa plano.
* `:wrap` — `true` (default) envuelve bajo la llave del tag; `false`
devuelve solo el cuerpo.
"""
@spec to_map(t() | nil) :: map() | nil
def to_map(nil), do: nil
def to_map(%__MODULE__{} = el), do: to_map(el, [])
@spec to_map(t() | nil, keyword()) :: map() | nil
def to_map(nil, _opts), do: nil
def to_map(%__MODULE__{} = el, opts) when is_list(opts) do
Cfdi.Xml.Element.__to_map__(el, __MODULE__, opts)
end
unquote(to_element_arity)
defoverridable tag: 0,
namespace: 0,
local_name: 0,
to_map: 1,
to_map: 2,
to_element: 1
end
end
@doc false
def __split_tag__(tag) when is_binary(tag) do
case String.split(tag, ":", parts: 2) do
[ns, local] -> {ns, local}
[local] -> {nil, local}
end
end
@doc false
# Proyección genérica struct → mapa. Soporta opción `:ns` (incluir prefijo)
# y `:wrap` (envolver bajo la llave del tag). Se llama desde cada módulo.
def __to_map__(struct, module, opts) do
ns? = Keyword.get(opts, :ns, true)
wrap? = Keyword.get(opts, :wrap, true)
attr_names = module.__xml__(:attributes)
child_names = module.__xml__(:children)
attrs =
struct
|> Map.from_struct()
|> Map.take(attr_names)
|> Enum.reject(fn {_, v} -> is_nil(v) end)
|> Map.new()
kids =
struct
|> Map.from_struct()
|> Map.take(child_names)
|> Enum.reject(fn {_, v} -> is_nil(v) or v == [] end)
|> Map.new(fn {k, v} -> {__stringify_child_key__(k, ns?), __project_child__(v, ns?)} end)
body = Map.merge(attrs, kids)
if wrap? do
%{tag_key(module, ns?) => body}
else
body
end
end
@doc false
def __stringify_child_key__(k, _ns?) when is_binary(k), do: k
def __stringify_child_key__(k, ns?) when is_atom(k) do
s = Atom.to_string(k)
cond do
not String.contains?(s, ":") -> k
ns? -> s
true -> s |> String.split(":", parts: 2) |> List.last()
end
end
@doc false
def __project_child__(v, ns?) when is_list(v), do: Enum.map(v, &__project_child__(&1, ns?))
def __project_child__(%mod{} = s, ns?) do
if function_exported?(mod, :to_map, 2) do
mod.to_map(s, ns: ns?, wrap: false)
else
s |> Map.from_struct() |> Enum.reject(fn {_, v} -> is_nil(v) end) |> Map.new()
end
end
def __project_child__(v, _ns?), do: v
defp tag_key(module, true), do: module.tag()
defp tag_key(module, false), do: module.local_name()
@doc false
# Shared attribute-building logic used by the macro-generated to_element/1.
# Takes a struct and its module, emits the attribute map for XmlBuilder,
# including xmlns:* entries when the element has namespaces declared.
def __build_attrs__(struct, module) do
attr_names = module.__xml__(:attributes)
namespaces = module.__xml__(:namespaces)
base =
struct
|> Map.from_struct()
|> Map.take(attr_names)
|> Enum.reject(fn {_, v} -> is_nil(v) end)
|> Map.new(fn {k, v} -> {Atom.to_string(k), to_string(v)} end)
Enum.reduce(namespaces, base, fn {prefix, uri}, acc ->
Map.put(acc, "xmlns:#{Atom.to_string(prefix)}", uri)
end)
end
# Maps the DSL type atom to an AST for `@type t`.
defp type_to_spec(:string), do: quote(do: String.t() | nil)
defp type_to_spec(:integer), do: quote(do: integer() | nil)
defp type_to_spec(:decimal), do: quote(do: String.t() | Decimal.t() | nil)
defp type_to_spec(:datetime), do: quote(do: String.t() | DateTime.t() | nil)
defp type_to_spec(:boolean), do: quote(do: boolean() | nil)
defp type_to_spec(:list), do: quote(do: list() | nil)
defp type_to_spec(:map), do: quote(do: map() | nil)
defp type_to_spec(_other), do: quote(do: term() | nil)
end