defmodule XmlSchema do @moduledoc """ Adds XML parsing and generation to modules via `use XmlSchema`. Define Xml structures with: - [xml](#xml/1) - [xml_tag](#xml_tag/3) - [xml_one](#xml_one/3) - [xml_many](#xml/3) Options to `use`: `tag: ` string to use as xml tag name for this element. This allows a tag name not derived from the module name. `print: ` will print the macro generated code, e.g: `print: Mix.env() in [:test, :dev]` # Generated functions An XmlSchema will provide the generated functions: - parse_xml/1 - accepts an xml string to parse - xml_tag/0 - returns the tag used when generating xml from this node - xml_tag_list - order list of tags in this schema - transform/1 - overridable function that can be used to transform tag names during parsing # Example ```elixir defmodule Simple do use XmlSchema, xml_name: "a" xml do xml_tag :x, :string xml_tag :y, :boolean xml_one :z, Z do xml_tag :a, :string xml_tag :b, :string end xml_many :j, J do xml_tag :q, :string end xml_tag :g, {:array, :string} end end ``` Will parse ```xml hill false tree bush cat dog hippo elephant rhino ``` Into: ```elixir %Simple{ _attributes: %{"otherattr" => "red", "someattr" => "blue"}, x: "hill", y: false, z: %Simple.Z{_attributes: nil, a: "tree", b: "bush"}, j: [ %Simple.J{_attributes: nil, q: "cat"}, %Simple.J{_attributes: nil, q: "dog"} ], g: ["hippo", "elephant", "rhino"] } ``` # Attributes Parsed attibutes are stored in a map, this precludes multiple same named attribute in a single tag. It also does not preserve attribute ordering. Terminal nodes defined with `xml_tag` provide bare values. If you wish to capture attributes the type argument takes the form of `{:param, type}` which will result in not bare values but a tuple of `{value, %{attribute map}}`. An example: ```elixir #{File.read!("test/support/example/attribute.ex")} ``` For more examples, see test/support. """ @doc """ Start definition of xml document structure. """ defmacro xml(block) do quote do @primary_key false embedded_schema do field :_attributes, :map unquote(block) end end end @doc """ Declare an XML tag with a scalar value with type conversion. Any type accepted by Ecto.Schema can be used. Custom types can be defined in the same manner. An xml_tag is terminal, for tags containing substructure use xml_one or xml_many. See attributes section above for details on how to capture tag attributes. ``` #{File.read!("test/support/example/tag.ex")} ``` """ defmacro xml_tag(name, type, opts \\ []) do Module.put_attribute(__CALLER__.module, :tag_order, name) quote do field unquote(name), unquote(type), unquote(opts) end end @doc """ Declare a tag that occurs once with XML tag `name` as either a block or a declared module. Modules generated by blocks automatically `use XmlSchema`. nil value is used if a tag is not present. Block form example: ``` #{File.read!("test/support/example/one_block.ex")} ``` In this example (from test/support/example/one.ex) `doc` is an xml sample that will result in the `expect` struct. Defined module form: ``` #{File.read!("test/support/example/one.ex")} ``` Depends on the definition of module `Example.Tag`. """ defmacro xml_one(name, schema, opts \\ []) defmacro xml_one(name, schema, do: block) do Module.put_attribute(__CALLER__.module, :tag_order, name) block = quote do use XmlSchema xml do unquote(block) end end new_name = Macro.expand(schema, __CALLER__) module = Module.concat(__CALLER__.module, new_name) Module.create(module, block, __CALLER__) quote do embeds_one(unquote(name), unquote(module), []) end end defmacro xml_one(name, schema, opts) do Module.put_attribute(__CALLER__.module, :tag_order, name) quote do embeds_one(unquote(name), unquote(schema), unquote(opts)) end end @doc """ Similar to [`xml_one/3`](#xml_one/3) except the tag can occur 0 or more times. Values will be presented in a list. Unlike `xml_one` if the tag is not present an empty list will result. In this example (from test/support/example/many.ex), `doc` is an xml sample that will result in the `expect` struct. ``` #{File.read!("test/support/example/many.ex")} ``` """ defmacro xml_many(name, schema, opts \\ []) defmacro xml_many(name, schema, do: block) do Module.put_attribute(__CALLER__.module, :tag_order, name) block = quote do use XmlSchema xml do unquote(block) end end new_name = Macro.expand(schema, __CALLER__) module = Module.concat(__CALLER__.module, new_name) Module.create(module, block, __CALLER__) quote do embeds_many(unquote(name), unquote(module), []) end end defmacro xml_many(name, schema, opts) do Module.put_attribute(__CALLER__.module, :tag_order, name) quote do embeds_many(unquote(name), unquote(schema), unquote(opts)) end end defmodule ParseError do @moduledoc "Raised when type conversion from xml string fails" defexception message: "probem parsing xml into schema" end @doc """ Parse an xml string based on the structure of `module`, which must be an XmlSchema. This function is added to any module that uses XmlSchema and is simplest to call the function there instead of this one. """ def parse_xml(xml_string, module) do :erlsom.simple_form(xml_string) |> case do {:ok, sxml, _extra} -> unpack_xml(sxml, module) other -> other end end defp unpack_xml({_tag, attr, children}, module) do Enum.reduce(children, module.__struct__(), &unpack_tag(&1, module, &2)) |> add_attrs(attr) end defp add_attrs(ms, []), do: ms defp add_attrs(ms, attr) do %{ ms | :_attributes => attr_translate(attr) } end defp attr_translate(attr) do Map.new(attr, fn {k, v} -> {to_string(k), to_string(v)} end) end @nsre Regex.compile!("^({(?[^}]+)})?(?.*)") defp ns_tag(tag, attr) do with [?{ | _] <- tag, str_tag <- to_string(tag), %{"ns" => ns, "tag" => bare_tag} <- Regex.named_captures(@nsre, str_tag) do {bare_tag, [{~c"_ns", ns} | attr]} else _unscoped -> {to_string(tag), attr} end end defp unpack_tag({tag, attr_in, child}, module, acc) do {str_field, attr} = ns_tag(tag, attr_in) module.get_tag(str_field) |> case do nil -> try_transform({str_field, attr, child}, module, acc) field when is_atom(field) -> unpack_tag_aux(field, {tag, attr, child}, module, acc) end end defp unpack_tag_aux(field, {tag, attr, child}, module, acc) do type = module.__schema__(:type, field) case type do nil -> acc {:array, subtype} -> update_array(acc, field, charlist_to_type(child, subtype, field, attr)) {:parameterized, Ecto.Embedded, params} -> embed(acc, field, {tag, attr, child}, params) subtype -> Map.put(acc, field, charlist_to_type(child, subtype, field, attr)) end end defp subtype_attr({value, nil}, attr) do {value, attr_translate(attr)} end defp subtype_attr({value, %{} = attr_map}, attr) do {value, Map.merge(attr_map, attr_translate(attr))} end defp subtype_attr(st, _attr), do: st defp try_transform({tag, attr, child}, module, acc) do tag |> module.transform() |> module.get_tag() |> case do nil -> acc ^tag -> acc new_tag -> unpack_tag_aux(new_tag, {new_tag, [{~c"_tag", tag} | attr], child}, module, acc) end end defp embed(base, field, val, %{cardinality: :one} = params) do Map.put(base, field, unpack_xml(val, params.related)) end defp embed(base, field, val, %{cardinality: :many} = params) do cur = Map.get(base, field, []) cur = cur ++ [unpack_xml(val, params.related)] Map.put(base, field, cur) end defp update_array(base, field, val) do Map.get(base, field) |> case do nil -> Map.put(base, field, [val]) list -> Map.put(base, field, list ++ [val]) end end defp charlist_to_type([], _type, _field, _attr), do: nil defp charlist_to_type([cl], type, _field, attr) when is_list(cl) do # IO.puts( "charlist_to_type: val=#{inspect(cl)} type=#{type}") cond do is_atom(type) and Code.ensure_compiled(type) == {:module, type} and function_exported?(type, :type, 0) -> type.cast(cl) true -> case type do :atom -> {:ok, List.to_atom(cl)} :string -> {:ok, List.to_string(cl)} :boolean -> Ecto.Type.cast(:boolean, List.to_string(cl) |> String.downcase()) {:param, subtype} -> {:ok, {charlist_to_type([cl], subtype, nil, attr), nil}} prim -> Ecto.Type.cast(prim, List.to_string(cl)) end |> case do {:ok, val} -> val other -> other end end |> subtype_attr(attr) end defp charlist_to_type(other, type, field, _attr) do raise ParseError, message: "tag #{field} to #{type} is not a string input: #{inspect(other)}" end defp generate_custom(maybe_custom, fld_c, list_comp_val, acc) when is_list(list_comp_val) do Enum.reduce(list_comp_val, acc, fn sval, acc2 -> generate_custom(maybe_custom, fld_c, sval, acc2) end) end defp generate_custom(maybe_custom, fld_c, comp_val, acc) do {val, attr} = case comp_val do {v1, a1} when is_map(a1) -> {v1, Map.to_list(a1)} v1 -> {v1, []} end cond do Ecto.Type.base?(maybe_custom) -> [{fld_c, attr, val} | acc] is_atom(maybe_custom) and Code.ensure_compiled(maybe_custom) == {:module, maybe_custom} and function_exported?(maybe_custom, :dump, 1) -> [{fld_c, attr, maybe_custom.dump(val)} | acc] true -> acc end end defp generate_value_array({:array, ptype} = type, fld_c, [h | t], acc) do generate_value_array(type, fld_c, t, generate_value(ptype, fld_c, h, acc)) end defp generate_value_array(_type, _fld_c, [], acc), do: acc defp generate_value(type, fld_c, val, acc) do case type do {:parameterized, Ecto.Embedded, %{cardinality: :one}} -> [generate_child(val, fld_c) | acc] {:parameterized, Ecto.Embedded, %{cardinality: :many}} -> [Enum.map(val, &generate_child(&1, fld_c)) | acc] {:array, _ptype} -> generate_value_array(type, fld_c, Enum.reverse(val), acc) {:param, ptype} -> generate_custom(ptype, fld_c, val, acc) _maybe_custom -> generate_custom(type, fld_c, val, acc) end end defp generate_child(schema, as_tag) do attr = (Map.get(schema, :_attributes) || %{}) |> Map.to_list() children = schema.__struct__.xml_tag_list() |> Enum.reverse() |> Enum.reduce([], fn :_attributes, acc -> acc fld, acc -> fld_c = to_charlist(fld) Map.get(schema, fld) |> case do nil -> acc [] -> acc val -> schema.__struct__.__schema__(:type, fld) |> generate_value(fld_c, val, acc) end end) {as_tag, attr, children} end @doc """ Generate xml document from xml schema """ def generate_xml(schema) do generate_xml(schema, schema.__struct__.xml_tag()) end @doc "Generate xml document from xml schema with `name` top tag" def generate_xml(schema, name) do generate_child(schema, name) |> XmlBuilder.document() |> XmlBuilder.generate(pretty: true) end defp module_tail_to_string(module) do [name | _] = Module.split(module) |> Enum.reverse() name end defp code_generate(xml_name) do quote bind_quoted: [xml_name: xml_name] do @moduledoc "See XmlSchema for information" use Ecto.Schema import XmlSchema @before_compile XmlSchema.Precompile @doc "Parse xml_string using #{xml_name} as starting point" def parse_xml(xml_string) do XmlSchema.parse_xml(xml_string, __MODULE__) end @doc "return tag name to use when generating xml" def xml_tag, do: unquote(xml_name) @doc "list of ordered tags in this schema" def xml_tag_list, do: Enum.reverse(@tag_order) @doc "Overridable function for transforming input tags" def transform(tag), do: tag defoverridable transform: 1 end end defmacro __using__(opts) do xml_name = Keyword.get(opts, :tag, module_tail_to_string(__CALLER__.module)) Module.register_attribute(__CALLER__.module, :tag_order, accumulate: true) code_generate(xml_name) |> tap(fn gen -> if Keyword.get(opts, :print, false), do: IO.puts("XmlSchema generation for #{__CALLER__.module}:\n" <> Macro.to_string(gen)) end) end end