1

I have a hashtable with incoming parameters, for example (the number of parameters and the parameters themselves may vary):

$inParams = @{
  mode = "getevents"
  username = "vbgtut"
  password = "password"
  selecttype = "one"
  itemid = 148
  ver = 1
}

I need to convert the hashtable to the XML-RPC format described in the specification.

That is, for a hashtable, I have to use the struct data type described in the specification. The struct structure contains members corresponding to the key-value pairs of the hashtable.

The values in the members can be one of six scalar types (int, boolean, string, double, dateTime.iso8601 and base64) or one of two composite ones (struct and array). But for now, two scalar types are enough for me: string and int.

I wrote the following function for this:

function toXML($hashT) {
  $xml =  '<?xml version="1.0"?>'
  $xml += "<methodCall>"
  $xml += "<methodName>LJ.XMLRPC." + $hashT["mode"] + "</methodName>"
  $xml += "<params><param><value><struct>"
  foreach ($key in $hashT.Keys) {
    if ($key -ne "mode") {
      $xml += "<member>"
      $xml += "<name>" + $key + "</name>"
      $type = ($hashT[$key]).GetType().FullName
      if ($type -eq "System.Int32") {$type = "int"} else {$type = "string"}
      $xml += "<value><$type>" + $hashT[$key] + "</$type></value>"
      $xml += "</member>"
    }
  }
  $xml += "</struct></value></param></params>"
  $xml += "</methodCall>"
  return $xml
}

This function is doing its job successfully (I'm using PowerShell 7 on Windows 10):

PS C:\> $body = toXML($inParams)
PS C:\> $body
<?xml version="1.0"?><methodCall><methodName>LJ.XMLRPC.getevents</methodName><params><param><value><struct><member><name>ver</name><value><int>1</int></value></member><member><name>username</name><value><string>vbgtut</string></value></member><member><name>itemid</name><value><int>148</int></value></member><member><name>selecttype</name><value><string>one</string></value></member><member><name>password</name><value><string>password</string></value></member></struct></value></param></params></methodCall>

I do not need to receive XML in a readable form, but for this question I will bring XML in a readable form for an example:

PS C:\> [System.Xml.Linq.XDocument]::Parse($body).ToString()
<methodCall>
  <methodName>LJ.XMLRPC.getevents</methodName>
  <params>
    <param>
      <value>
        <struct>
          <member>
            <name>ver</name>
            <value>
              <int>1</int>
            </value>
          </member>
          <member>
            <name>username</name>
            <value>
              <string>vbgtut</string>
            </value>
          </member>
          <member>
            <name>itemid</name>
            <value>
              <int>148</int>
            </value>
          </member>
          <member>
            <name>selecttype</name>
            <value>
              <string>one</string>
            </value>
          </member>
          <member>
            <name>password</name>
            <value>
              <string>password</string>
            </value>
          </member>
        </struct>
      </value>
    </param>
  </params>
</methodCall>

My question is as follows: Is it possible in PowerShell to convert a hashtable to XML-RPC format in a more optimal way than in my ToXML function?

I tried using the cmdlet ConvertTo-Xml. It works well, but converts the hashtable to regular XML, not to XML-RPC format. Maybe this cmdlet can be configured somehow so that it works in XML-RPC format?

I also heard about the library xml-rpc.net, but her website is unavailable. It looks like this library is no longer being developed. Because of this, I am afraid to use it. Is it worth trying to use it?

Ilya Chalov
  • 149
  • 2
  • 9
  • 1
    `ConvertTo-Xml` offers no customizations (and is virtually useless in general, as there's no complementary tooling to _parse_ the resulting XML format). Indeed, XML-RPC seems to be dying. You can look for potentially relevant, still-maintained libraries in the NuGet Gallery, https://www.nuget.org/packages?q=xml-rpc&frameworks=&tfms=&packagetype=&prerel=true&sortby=created-desc - though you'd have to check whether they support the stand-alone to-XML serialization you're after. – mklement0 Feb 12 '23 at 16:29
  • 1
    @mklement0 Yes, it also seems to me that the XML-RPC protocol is dying. However, there are still sites that use it. Specifically, I want to get data from the [LiveJournal website](https://en.wikipedia.org/wiki/LiveJournal). – Ilya Chalov Feb 12 '23 at 17:20

3 Answers3

2

Currently your function uses a lot of += lines to construct a string, but that is very time and memory consuming.
You could consider using a StringBuilder object for that or a List object that has an .Add() method.

Another approach is to use two template Here-Strings you can use to fill in the data from the Hashtable. Something like this:

function ConvertTo-XmlRpc {
    param (
        [Hashtable]$hashT
    )
    # template for the xml
    $xmlTemplate = @'
<?xml version="1.0"?>
<methodCall>
    <methodName>LJ.XMLRPC.{0}</methodName>
    <params>
        <param>
            <value>
                <struct>
{1}
                </struct>
            </value>
        <param>
    </params>
</methodCall>
'@
    # template for a member node
    $memberTemplate = @'
                    <member>
                        <name>{0}</name>
                        <value>
                            <{1}>{2}</{1}>
                        </value>
                    </member>
'@
    # first construct the member nodes
    $members = foreach ($key in $hashT.Keys) {
        if ($key -ne "mode") {
            # just checking for 'int' or 'string' here, but you can extend to different types
            $type = if ($hashT[$key] -is [int] -or $hashT[$key] -match '^\d+$') {'int'} else {'string'}
            $memberTemplate -f $key, $type, $hashT[$key]
        }
    }
    # return the completed xml
    $xmlTemplate -f $hashT["mode"], ($members -join [environment]::NewLine)
}

$inParams = @{
    mode = "getevents"
    username = "vbgtut"
    password = "password"
    selecttype = "one"
    itemid = 148
    ver = 1
}

ConvertTo-XmlRpc $inParams

Note: I have renamed the function to comply to the Verb-Noun naming recommendations in PowerShell

Theo
  • 57,719
  • 8
  • 24
  • 41
  • 2
    For the benefit of the OP It’s worth noting that any string manipulation approach to xml (and csv, json etc) will have problems with special syntax characters - in this case if fields like the “password” contain “<“ or “>”, quotes etc it might generate invalid xml unless the values are escaped first… – mclayton Feb 12 '23 at 17:18
  • @Theo I have a string in the variable `$xml` (an object of the class `System.String`), not an array, but I get what you're writing about. I found [about it in the documentation](https://learn.microsoft.com/en-us/powershell/scripting/dev-cross-plat/performance/script-authoring-considerations?#string-addition). Thank you, this is a useful remark. – Ilya Chalov Feb 12 '23 at 17:52
  • @mclayton I use authentication like `challenge-response` or using `cookie`, so I don't use the `password` parameter, it's here for an example. But I understand what you're writing about. Thanks for that comment, it didn't occur to me. – Ilya Chalov Feb 12 '23 at 17:58
  • @IlyaChalov Yes, indeed you were building on a string, not an array. I have corrected that. I'm on mobile now, but if you want I can update the code to replace specific xml characters to valid entities. Probably not tomorrow though.. – Theo Feb 12 '23 at 19:20
1

As discussed, the XML-RPC protocol seems to be dying.

On the plus side, given that the protocol isn't evolving anymore, you may be able to use even older libraries, as long as they're still technically compatible with your runtime environment.

For instance, the third-party XmlRpc module, last updated in 2017, may still work for you (its data-type support doesn't include base64):

# Install the module, if necessary
if (-not (Get-Module -ListAvailable XmlRpc)) {
  Write-Verbose -Verbose "Installing module 'XmlRpc' in the current user's scope..."
  Install-Module -ErrorAction Stop -Scope CurrentUser XmlRpc
}

# Sample call
$result = 
  ConvertTo-XmlRpcMethodCall -Name LJ.XMLRPC.getevents @{
    username = "vbgtut"
    password = "password"
    selecttype = "one"
    itemid = 148
    ver = 1
    # ... sample values to demonstrate full data-type support and XML escaping
    array = 'one & two', 3
    double = 3.14
    boolean = $true
    date = Get-Date
  } 

# Post-process the results to make the data-type element
# names conform to the spec.
# ('Double' -> 'double', ..., and 'Int32' -> 'int')
[regex]::Replace(
  $result,
  '(</?)([A-Z]\w+)',
  { 
    param($m) 
    $m.Groups[1].Value + ($m.Groups[2].Value.ToLower() -replace '32$')
  }
)

This produces the following output, but note:

  • As you've discovered, the data-type XML element names used by ConvertTo-XmlRpcMethodCall (the underlying ConvertTo-XmlRpcType) aren't spec-compliant, in that they start with a capital letter when they should all be all-lowercase (e.g. <String> instead of <string>), and <Int32> should be <int>. This problem is corrected in a post-processing step via [regex]::Replace(), which isn't ideal, but should work well enough in practice.

    • The problem has been reported in GitHub issue #4, though it's unclear if the module is still actively maintained.
  • For simplicity, I've removed the mode = "getevents" entry from the hashtable and have included its value as a literal part of the method name passed to -Name.

  • The real output isn't pretty-printed; it was done here for readability.

  • Because the command operates on - inherently unordered - hashtables, the entry-definition order isn't guaranteed - though that shouldn't matter. (Unfortunately, the module isn't designed to accept ordered hashtables ([ordered] @{ ... })).

  • Note how the & in 'one & two' was properly escaped as &amp;

<?xml version="1.0"?>
<methodCall>
  <methodName>LJ.XMLRPC.getevents</methodName>
  <params>
    <param>
      <value>
        <struct>
          <member>
            <name>date</name>
            <value>
              <dateTime.iso8601>20230212T15:33:52</dateTime.iso8601>
            </value>
          </member>
          <member>
            <name>username</name>
            <value>
              <string>vbgtut</string>
            </value>
          </member>
          <member>
            <name>selecttype</name>
            <value>
              <string>one</string>
            </value>
          </member>
          <member>
            <name>double</name>
            <value>
              <double>3.14</double>
            </value>
          </member>
          <member>
            <name>boolean</name>
            <value>
              <boolean>True</boolean>
            </value>
          </member>
          <member>
            <name>array</name>
            <value>
              <array>
                <data>
                  <value>
                    <string>one &amp; two</string>
                  </value>
                  <value>
                    <int>3</int>
                  </value>
                </data>
              </array>
            </value>
          </member>
          <member>
            <name>itemid</name>
            <value>
              <int>148</int>
            </value>
          </member>
          <member>
            <name>password</name>
            <value>
              <string>password</string>
            </value>
          </member>
          <member>
            <name>ver</name>
            <value>
              <int>1</int>
            </value>
          </member>
        </struct>
      </value>
    </param>
  </params>
</methodCall>
mklement0
  • 382,024
  • 64
  • 607
  • 775
  • I didn't know that comments could be deleted by other people or automatically. It's bad. – Ilya Chalov Feb 13 '23 at 16:58
  • 1
    @IlyaChalov, only moderators have the power to unilaterally delete comments. Regular users can _flag_ comments, using one of several reasons, one of them being "No longer needed". While that is useful for cleaning up comments that have served their purpose, so as to reduce "noise" for future readers, it is unfortunate when that happens too soon, and especially when only _one half_ of the conversation is deleted, which is what happened here originally. Only moderators would be able to tell who did what here. – mklement0 Feb 13 '23 at 17:06
  • 1
    I accepted your answer, as the package you specified looks like the right solution. It will be a good solution if the package author makes corrections to it. But it looks like the author is not developing his package. A few minor remarks: the `mode = "getevents"` pair should not get into `struct`, since `getevents` is already in `methodName`; the fact that the hashtable is unordered is the way it should be. – Ilya Chalov Feb 13 '23 at 17:09
  • 1
    Thanks, @IlyaChalov. I've have removed the `mode = "getevents"` entry from the sample hashtable (and output), and have added a note that I'm taking a shortcut there (I think it's better to keep the code simple). Yes, it seems unlikely that the module will be fixed, but you could certainly create your own fork, or perhaps even reach out to the author to see if he's willing to transfer ownership of the repo to you, so you can maintain it for the community going forward. – mklement0 Feb 13 '23 at 17:20
  • P.S., @IlyaChalov, our comments about the disappearing comment are classic examples of comments that deserve to be cleaned up eventually, but obviously only after _both_ conversation partners have seen them. For instance, once you have read this comment, you could delete your first comment and flag mine as "No longer needed". I do wish there were a _semi_-automatic mechanism for this, where a comment author could designate a comment as "ephemeral", giving the addressee the option to delete it after reading. – mklement0 Feb 13 '23 at 17:25
  • I don't agree with the paradigm of "temporary" comments that can be erased at any time. But I understand that I am a guest on this site and must respect the local rules. Now I will take measures not to lose the comments that I consider important. – Ilya Chalov Feb 13 '23 at 17:36
  • @IlyaChalov, to be clear: I agree that comments shouldn't ever be deleted _automatically_. I was proposing a _voluntary, collaborative_ mechanism for making _intentional, self-chosen_ removal (cleanup) easier. The flagging mechanism has its value too, but it relies on the moderators to make the right decisions - to separate truly no-longer-needed comments from those that are of long-term interest - and mistakes are inevitably made. – mklement0 Feb 13 '23 at 17:41
1

I was inspired by @Theo's answer and changed my approach to writing this function. I used the 'Unix philosophy' (writing small functions that work together and use text exchange) and used the fact that the XML tree (that is, an XML-RPC tree) has the property of recursiveness.

I took into account @Theo's remark about adding strings (performance issue) and replaced all the concatenation operators + and += with one format operator -f for each function (and I also used the -join operator).

I took into account @mclayton's remark about the need to escape some characters in strings. Indeed, the XML-RPC format specification requires escaping only two characters: & and < (replaced by &amp; and &lt;). After that, escaping the > character is not required.

To the previously required processing of hashtables, strings and integers, I added array processing. Now it is possible to process hash tables and arrays nested in each other.

Here's what I got (six functions):

function toXMLScalar($val) {
  $type = $val.GetType().Name
  if ($type -eq "Int32") {$type = "int"} else {
    $type = "string"
    $val = $val -replace '&', '&amp;'
    $val = $val -replace '<', '&lt;'
  }
  "<{0}>{1}</{0}>" -f $type, $val
}
function toXMLValue($val) {
  $val = if ($val -is [System.Array]) {
    toXMLArray $val
  } elseif ($val.GetType().Name -eq "Hashtable") {
    toXMLStruct $val
  } else {
    toXMLScalar $val
  }
  "<value>{0}</value>" -f $val
}
function toXMLArray($arr) {
  $values = foreach ($elem in $arr) { toXMLValue $elem }
  $values = -join $values
  "<array><data>{0}</data></array>" -f $values
}
function toXMLMember($key, $val) {
  $val = toXMLValue $val
  "<member><name>{0}</name>{1}</member>" -f $key, $val
}
function toXMLStruct($hashT) {
  $members = foreach ($key in $hashT.Keys) {
    if ($key -ne "mode") {
      toXMLMember $key $hashT[$key]
    }
  }
  $members = -join $members
  "<struct>{0}</struct>" -f $members
}
function toXML($hashT) {
  $xml = -join @(
    '<?xml version="1.0"?>'
    "<methodCall><methodName>LJ.XMLRPC.{0}</methodName>"
    "<params><param>{1}</param></params></methodCall>"
  )
  $value = toXMLValue $hashT
  $xml -f $hashT["mode"], $value
}

This approach has a disadvantage: with a large nesting of hash tables and/or arrays, it is possible to reach the call stack limit. People write that since the second version of PowerShell, this limit is 1000 calls. But I don't need a lot of nesting depth, so a recursive approach suits me.

To use these functions, as before, you need to make such a call:

$body = toXML($inParams)

Next, the toXML function will call the toXMLValue function, it will call the toXMLStruct function, and so on.

Ilya Chalov
  • 149
  • 2
  • 9