lib/xml_rpc/decoder.ex

defmodule XMLRPC.DecodeError do
  defexception message: nil
end

defmodule XMLRPC.Decoder do

  alias XMLRPC.DecodeError
  alias XMLRPC.Fault
  alias XMLRPC.MethodCall
  alias XMLRPC.MethodResponse

  # Load our XML Schema from an external file
  @xmlrpc_xsd_file Path.join(__DIR__, "xmlrpc.xsd")
  @external_resource @xmlrpc_xsd_file
  @xmlrpc_xsd File.read!(@xmlrpc_xsd_file)

  @moduledoc """
  This module does the work of decoding an XML-RPC call or response.
  """

  @doc """
  Decode an XML-RPC Call or Response object

  Input:
  iodata consisting of the input XML string
  options:
    exclude_nil: false (default) - allow decoding of <nil/> values

  Output:
  On any parse failure raises XMLRPC.DecodeError

  On success the decoded result will be a struct, either:
  * XMLRPC.MethodCall
  * XMLRPC.MethodResponse
  * XMLRPC.Fault
  """
  def decode!(iodata, options) do
    {:ok, model} = :erlsom.compile_xsd(@xmlrpc_xsd)
    xml = IO.iodata_to_binary(iodata)

    case :erlsom.scan(xml, model, [{:output_encoding, :utf8}]) do
      {:error, [{:exception, {_error_type, {error}}}, _stack, _received]} when is_list(error) ->
          raise DecodeError, message: List.to_string(error)
      {:error, [{:exception, {_error_type, error}}, _stack, _received]} ->
          raise DecodeError, message: error
      {:error, message} when is_list(message) ->
          raise DecodeError, message: List.to_string(message)
      {:ok, struct, _rest} ->
          parse(struct, options)
    end

  end

  # ##########################################################################
  # Top level parsers.
  # Pickup the main type of the thing being parsed and setup appropriate result objects

  # Parse a method 'Call'
  defp parse(  {:methodCall, [], method_name,
                {:"methodCall/params", [], params }},
               options )
      when is_list(params)
  do
    %MethodCall{ method_name: method_name, params: parse_params(params, options) }
  end

  # Parse a method 'Call' with no (:undefined) params
  defp parse(  {:methodCall, [], method_name,
                {:"methodCall/params", [], :undefined }},
               options )
  do
    %MethodCall{ method_name: method_name, params: parse_params([], options) }
  end

  # Parse a method 'Call' with completely missing params array
  defp parse(  {:methodCall, [], method_name,
                :undefined},
               options )
  do
    %MethodCall{ method_name: method_name, params: parse_params([], options) }
  end

  # Parse a 'fault' Response
  defp parse(  {:methodResponse, [],
                {:"methodResponse/fault", [],
                  {:"methodResponse/fault/value", [],
                    {:"methodResponse/fault/value/struct", [], fault_struct} }}},
                options )
      when is_list(fault_struct)
  do
    fault = parse_struct(fault_struct, options)
    fault_code = Map.get(fault, "faultCode")
    fault_string = Map.get(fault, "faultString")
    %Fault{ fault_code: fault_code, fault_string: fault_string }
  end

  # Parse any other 'Response'
  defp parse(  {:methodResponse, [],
                {:"methodResponse/params", [], param}},
               options )
      when is_tuple(param)
  do
    %MethodResponse{ param: parse_param(param, options) }
  end

  # ##########################################################################

  # Parse an 'array' atom
  defp parse_value( {:ValueType, [], [{:ArrayType, [], {:"ArrayType/data", [], array}}]}, options ) do
    parse_array(array, options)
  end

  # Parse a 'struct' atom
  defp parse_value( {:ValueType, [], [{:StructType, [],                   struct}]}, options)
      when is_list(struct)
  do
    parse_struct(struct, options)
  end

  defp parse_value( {:ValueType, [], [{:StructType, [], _struct}]}, _options) do
    %{}
  end

  # Parse an 'integer' atom
  defp parse_value( {:ValueType, [], [{:"ValueType-int", [],              int}]}, _options)
      when is_integer(int)
  do
      int
  end

  # Parse an 'i4' atom (32 bit integer)
  defp parse_value( {:ValueType, [], [{:"ValueType-i4", [],              int}]}, _options)
      when is_integer(int)
  do
      int
  end

  # Parse an 'i8' atom (64 bit integer)
  defp parse_value( {:ValueType, [], [{:"ValueType-i8", [],              int}]}, _options)
      when is_integer(int)
  do
      int
  end

  # Parse a 'float' atom
  defp parse_value( {:ValueType, [], [{:"ValueType-double", [],           float}]}, _options) do
    Float.parse(float)
    |> elem(0)
  end

  # Parse a 'boolean' atom
  defp parse_value( {:ValueType, [], [{:"ValueType-boolean", [],          boolean}]}, _options) do
    case boolean do
      "0" -> false
      "1" -> true
    end
  end

  # Parse a 'datetime' atom (needs decoding from bolloxed iso8601 alike format...)
  defp parse_value( {:ValueType, [], [{:"ValueType-dateTime.iso8601", [], datetime}]}, _options) do
    %XMLRPC.DateTime{raw: datetime}
  end

  # Parse a 'base64' atom
  defp parse_value( {:ValueType, [], [{:"ValueType-base64", [],           string}]}, _options) do
    %XMLRPC.Base64{raw: string}
  end

  # Parse an empty 'string' atom
  defp parse_value( {:ValueType, [], [{:"ValueType-string", [],           []}]}, _options) do
    ""
  end

  # Parse a 'string' atom
  defp parse_value( {:ValueType, [], [{:"ValueType-string", [],           string}]}, _options) do
    string
  end

  # A string value can optionally drop the type specifier. The node is assumed to be a string value
  defp parse_value( {:ValueType, [], [string]                                     }, _options) when is_binary(string) do
    string
  end

  # An empty string that drops the type specifier will parse as :undefined instead of an empty binary.
  defp parse_value( {:ValueType, [], :undefined                                   }, _options) do
    ""
  end

  # Parse a 'nil' atom
  # Note: this is an xml-rpc extension
  defp parse_value( {:ValueType, [], [NilType: []]}, options) do
    if options[:exclude_nil] do
      raise XMLRPC.DecodeError, message: "unable to decode <nil/>"
    else
      nil
    end
  end

  # ##########################################################################

  # Parse the 'struct'
  # 'structs' are a list of key-value pairs
  # Note: values can be 'structs'/'arrays' as well as other atom types
  defp parse_struct(doc, options) when is_list(doc) do
    doc
    |> Enum.reduce(Map.new,
                        fn(member, acc) ->
                            parse_member(member, options)
                            |> Enum.into(acc)
                        end)
  end

  # Parse the 'array'
  # 'arrays' are just an ordered list of other atom values
  # Note: values can be 'structs'/'arrays' as well as other atom types
  defp parse_array(doc, options) when is_list(doc) do
    doc
    |> Enum.map(fn v -> parse_value(v, options) end)
  end

  # Empty array, ie <array><data/></data>
  defp parse_array(:undefined, _options), do: []

  # ##########################################################################

  # Parse a list of Parameter values (implies a Request)
  defp parse_params(values, options) when is_list(values) do
    values
    |> Enum.map(fn p -> parse_param(p, options) end)
  end

  # Parse a single Parameter
  defp parse_param( {:ParamType, [], value }, options ), do: parse_value(value, options)

  # ##########################################################################

  # Parse one member of a Struct
  defp parse_member( {:MemberType, [], name, value }, options ) do
    [{name, parse_value(value, options)}]
  end

end