Validate XML with XSD in .NET and native MSXML – big difference in string maxLength validation with newlines (samples in C# and Delphi)
Posted by jpluimers on 2010/01/19
Recently, I had an issue while validating XML with XSD: validation in .NET using the built in classes in the System.XML namespace, and validation in native Windows using the COM objects exposed by MSXML version 6 (which incidentally ships with the .NET 3.0 framework).
Some documents validating OK in .NET did not validate well with MSXML.
I’ll show my findings below, and try to explain the difference I found, together with my conclusions.
The main conclusion is that MSXML version 6 has a bug, but I wonder why I can’t find much more information on it.
Since there is not so much ready to use for validating XML by XSD in .NET and native, I’ll include complete source code of command-line validations applications for both platforms.
.NET source code is in C#.
Native source code is in Delphi.
So lets get started with the XSD:
<?xml version="1.0" encoding="UTF-8"?> <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified" attributeFormDefault="unqualified"> <xs:element name="content"> <xs:simpleType> <xs:restriction base="xs:string"> <xs:maxLength value="25"/> </xs:restriction> </xs:simpleType> </xs:element> </xs:schema>
An XML document like the one directly below will validate OK with the XSD, both in .NET and with MSXML version 6:
<?xml version="1.0" encoding="UTF-8"?> <content>text</content>
The odd thing is, this XML file validates OK in .NET, but not in MSXML version 6:
<?xml version="1.0" encoding="UTF-8"?> <content>123456789 123456789 12345</content>
Even stranger is that both .NET and MSXML agree on the length of the content element in the above XML file: 25 characters, just like with this XML:
<?xml version="1.0" encoding="UTF-8"?> <content>1234567890123456789012345</content>
Just look at these two dumps of the above XML files from .NET:
NodeType=Element, NodeName=content NodeType=Text, NodeName= Length=25 LF-Count=2 CR-Count=0 123456789 123456789 12345 NodeType=Element, NodeName=content NodeType=Text, NodeName= Length=25 LF-Count=0 CR-Count=0 1234567890123456789012345
and from MSXML:
NodeType=1, NodeName=content NodeType=3, NodeName=#text NodeValue-Length=25, LF-Count=2, CR-Count=0 123456789 123456789 12345 NodeType=1, NodeName=content NodeType=3, NodeName=#text NodeValue-Length=25, LF-Count=0, CR-Count=0 1234567890123456789012345
So: for the XML, both .NET and MSXML agree on the length of content (both cases 25), and for the first case an LF count of 2, and a CR count of 0 (zero).
Even though the XML files here store their line endings as CRLF pairs (see the dumps below), they should be treated as one character.
Both the Microsoft documentation (end of the page) and the W3C documentation (section 2.11) state this.
000000: 3C 3F 78 6D 6C 20 76 65 72 73 69 6F 6E 3D 22 31 <?xml version="1
000010: 2E 30 22 20 65 6E 63 6F 64 69 6E 67 3D 22 55 54 .0" encoding="UT
000020: 46 2D 38 22 3F 3E 0D 0A 3C 63 6F 6E 74 65 6E 74 F-8"?>..<content
000030: 3E 31 32 33 34 35 36 37 38 39 0D 0A 31 32 33 34 >123456789..1234
000040: 35 36 37 38 39 0D 0A 31 32 33 34 35 3C 2F 63 6F 56789..12345</co
000050: 6E 74 65 6E 74 3E 0D 0A 0D 0A ntent>….
000000: 3C 3F 78 6D 6C 20 76 65 72 73 69 6F 6E 3D 22 31 <?xml version="1
000010: 2E 30 22 20 65 6E 63 6F 64 69 6E 67 3D 22 55 54 .0" encoding="UT
000020: 46 2D 38 22 3F 3E 0D 0A 3C 63 6F 6E 74 65 6E 74 F-8"?>..<content
000030: 3E 31 32 33 34 35 36 37 38 39 30 31 32 33 34 35 >123456789012345
000040: 36 37 38 39 30 31 32 33 34 35 3C 2F 63 6F 6E 74 6789012345</cont
000050: 65 6E 74 3E 0D 0A 0D 0A ent>….
So, I tried once more with this file which is LF separated:
000000: 3C 3F 78 6D 6C 20 76 65 72 73 69 6F 6E 3D 22 31 <?xml version="1
000010: 2E 30 22 20 65 6E 63 6F 64 69 6E 67 3D 22 55 54 .0" encoding="UT
000020: 46 2D 38 22 3F 3E 0A 3C 63 6F 6E 74 65 6E 74 3E F-8"?>.<content>
000030: 31 32 33 34 35 36 37 38 39 0A 31 32 33 34 35 36 123456789.123456
000040: 37 38 39 0A 31 32 33 34 35 3C 2F 63 6F 6E 74 65 789.12345</conte
000050: 6E 74 3E nt>
And now both .NET and MSXML version 6 have the same result: this file is valid in both cases.
Conclusion
From the above, I have a couple of conclusions.
- For XSD validation, MSXML does not adhere to the W3C standard with respect to CR LF end-of-line handling, but the .NET implementation does.
- Contrary to what a lot of people believe, the .NET XML implementation is not just a shell around MSXML.
- When validating XML with XSD using MSXML, you have to make sure you do not use CR LF end-of-lines. Normalize them to LF line endings (or CR line endings) before putting them through MSXML.
Source code
Since there is not so much sample XML with XSD validation sample source available in Delphi nor in C#/.NET, here are some samples.
For both environments, there are two samples:
- one for validating an XML document with one or more XSD documents,
- and one to dump one or more XML documents.
The C# samples are based on XMLReader, XmlReaderSettings and XmlSchema objects. They accommodate for includes in the XSD files (back when I wrote the original code, I had some nasty includes from relative directories).
The Delphi samples are based on MSXML, and use IXMLDOMDocument and IXMLDOMSchemaCollection. I did not add special code for includes in XSD files here, as the XSD files were all in once piece.
C# sample code
The C# sample code needs these assemblies
- – mscorlib.dll
- – System.dll
- – System.Xml.dll
C# console apps
using System; namespace bo.Xml { class ValidateXmlWithXsd { static void Main(string[] args) { if (2 != args.Length) { Console.WriteLine("use two parameters: XmlFile and XsdFile"); } else { logic instance = new logic(); try { instance.Run( args[0], args[1] ); } catch (Exception ex) { Console.WriteLine(ex); } } } } class logic { public void Run(string xmlFileName, string xsdFileName) { bo.Xml.XmlValidator validator = new bo.Xml.XmlValidator(); validator.XmlReadEventHandler += new bo.Xml.XmlReadEventHandler(validator_XmlReadEventHandler); validator.XmlValidationEventHandler += new bo.Xml.XmlValidationEventHandler(validator.ValidationResultEventHandler); validator.ValidateXml(xmlFileName, xsdFileName); string result = validator.ToString(); if (string.IsNullOrEmpty(result)) { result = "OK."; } Console.WriteLine(result); } void validator_XmlReadEventHandler(object sender, bo.Xml.XmlReadEventArgs e) { //if (reader.NodeType == XmlNodeType.Text) // Console.WriteLine(reader.Value); } } }
using System; namespace bo.Xml { class DumpXml { static void Main(string[] args) { if (1 > args.Length) { Console.WriteLine("use parameters: [XmlFile]..."); } else { logic instance = new logic(); try { instance.Run(args); } catch (Exception ex) { Console.WriteLine(ex); } } } } class logic { public void Run(string[] xmlFileNames) { bo.Xml.XmlDumper dumper = new bo.Xml.XmlDumper(); dumper.DumpXml(xmlFileNames); string result = dumper.ToString(); if (!string.IsNullOrEmpty(result)) Console.WriteLine(result); } } }
C# supporting classes
using System; using System.IO; using System.Text; using System.Xml; using System.Xml.Schema; namespace bo.Xml { public class XmlDumper { public void DumpXml(string[] xmlFileNames) { foreach (string xmlFileName in xmlFileNames) { DumpXml(xmlFileName); } } public void DumpXml(string xmlFileName) { using (FileStream xmlFile = File.OpenRead(xmlFileName)) { using (XmlReader reader = XmlReader.Create(xmlFile)) { while (reader.Read()) { string currentNodeAsString = GetCurrentNodeAsString(reader); if (!string.IsNullOrEmpty(currentNodeAsString)) { if (null == toString) toString = new StringBuilder(); toString.AppendLine(currentNodeAsString); } } } } } private StringBuilder toString = null; public override string ToString() { return (null == toString) ? null : toString.ToString(); } private int charCount(char compareValue, string value) { int result = 0; foreach (System.Char item in value) { if (item == compareValue) result++; } return result; } public string GetCurrentNodeAsString(XmlReader reader) { StringBuilder result = new StringBuilder(); switch (reader.NodeType) { case XmlNodeType.Element: case XmlNodeType.Text: AppendNodeTypeAndName(reader, result); break; default: break; } switch (reader.NodeType) { case XmlNodeType.Text: string value = reader.Value; int lfCount = charCount('\n', value); int crCount = charCount('\r', value); result.AppendFormat(" Length={0} LF-Count={1} CR-Count={2}", value.Length, lfCount, crCount); AppendValueOnNewLine(reader, result); break; case XmlNodeType.CDATA: case XmlNodeType.ProcessingInstruction: case XmlNodeType.Comment: case XmlNodeType.DocumentType: AppendValueOnNewLine(reader, result); break; default: break; } return result.ToString(); } private static void AppendNodeTypeAndName(XmlReader reader, StringBuilder result) { result.AppendFormat("NodeType={0}, NodeName={1}", reader.NodeType, reader.Name); } private static void AppendValueOnNewLine(XmlReader reader, StringBuilder result) { result.AppendLine(); result.Append(reader.Value); } } }
using System; using System.Collections.Generic; using System.IO; using System.Text; using System.Xml; using System.Xml.Schema; namespace bo.Xml { public class XmlReadEventArgs : EventArgs { public XmlReadEventArgs(XmlReader reader) : base() { this.reader = reader; } private XmlReader reader; public XmlReader Reader { get { return reader; } } } public class XmlValidationEventArgs : EventArgs { public XmlValidationEventArgs(XmlReader reader, System.Xml.Schema.ValidationEventArgs e) : base() { this.e = e; this.reader = reader; } private System.Xml.Schema.ValidationEventArgs e; public System.Xml.Schema.ValidationEventArgs E { get { return e; } } private XmlReader reader; public XmlReader Reader { get { return reader; } } } public delegate void XmlReadEventHandler(Object sender, XmlReadEventArgs e); public delegate void XmlValidationEventHandler(Object sender, XmlValidationEventArgs e); public class XmlValidator { public event XmlReadEventHandler XmlReadEventHandler; public event XmlValidationEventHandler XmlValidationEventHandler; public void ValidateXml(string xmlFileName, string xsdFileName) { ValidateXml(xmlFileName, new string[] { xsdFileName }); } public void ValidateXml(string xmlFileName, string[] xsdFileNames) { List<FileStream> xsdFileStreamsToDispose = new List<FileStream>(); try { XmlReaderSettings settings = new XmlReaderSettings(); settings.ValidationType = ValidationType.Schema; foreach (string xsdFileName in xsdFileNames) { // resolve the relative SchemaLocation of the included Xsd's using the // base path of the xsdFileName // see http://www.hanselman.com/blog/ValidatingXMLSchemasThatHaveBeenXsimportedAndHaveSchemaLocation.aspx // Refactoring idee: in de toekomst oplossen met een resolver (die recursief werkt) // see http://www.hanselman.com/blog/ValidatingThatXmlSchemasAndTheirImportsAreValidAndAllGoodWithAnXmlResolver.aspx FileStream xsdFileStream = File.OpenRead(xsdFileName); xsdFileStreamsToDispose.Add(xsdFileStream); XmlSchema schema = XmlSchema.Read(xsdFileStream, null); string baseDirectory = Path.GetDirectoryName(xsdFileName); foreach (XmlSchemaInclude include in schema.Includes) { // if include.SchemaLocation is an absolute path, Path.Combine returns that, // otherwise it appends the relative path to the baseDirectory include.SchemaLocation = Path.Combine(baseDirectory, include.SchemaLocation); } settings.Schemas.Add(schema); } settings.ValidationEventHandler += new ValidationEventHandler(reader_ValidationEventHandler); using (FileStream xmlFile = File.OpenRead(xmlFileName)) { using (reader = XmlReader.Create(xmlFile, settings)) { while (reader.Read()) { currentLine = GetCurrentNodeAsString(reader); OnReadEventHandler(); } } } } finally { foreach (IDisposable fileStream in xsdFileStreamsToDispose) { fileStream.Dispose(); } } } protected void OnReadEventHandler() { if (null != XmlReadEventHandler) { XmlReadEventHandler(this, new XmlReadEventArgs(reader)); } } private XmlReader reader; private string currentLine; private void reader_ValidationEventHandler(object sender, ValidationEventArgs e) { if (null != XmlValidationEventHandler) { XmlValidationEventHandler(this, new XmlValidationEventArgs(reader, e)); } } private StringBuilder toString = null; public override string ToString() { return (null == toString) ? null : toString.ToString(); } public void ValidationResultEventHandler(object sender, XmlValidationEventArgs e) { if (null == toString) { toString = new StringBuilder(); } toString.AppendFormat("{0}: {1}", e.E.Severity.ToString(), e.E.Message); toString.AppendLine(); if (!string.IsNullOrEmpty(currentLine)) toString.AppendLine(currentLine); string line = GetCurrentNodeAsString(e.Reader); if (!string.IsNullOrEmpty(line)) toString.AppendLine(line); } public string GetCurrentNodeAsString(XmlReader reader) { string result = ""; switch (reader.NodeType) { case XmlNodeType.Element: result = string.Format("<{0}>", reader.Name); break; case XmlNodeType.Text: result = reader.Value; break; case XmlNodeType.CDATA: result = string.Format("<![CDATA[{0}]]>", reader.Value); break; case XmlNodeType.ProcessingInstruction: result = string.Format("<?{0} {1}?>", reader.Name, reader.Value); break; case XmlNodeType.Comment: result = string.Format("<!--{0}-->", reader.Value); break; case XmlNodeType.XmlDeclaration: result = string.Format("<?{0} {1}?>", reader.Name, reader.Value); break; case XmlNodeType.Document: break; case XmlNodeType.DocumentType: result = string.Format("<!DOCTYPE {0} [{1}]", reader.Name, reader.Value); break; case XmlNodeType.EntityReference: toString.Append(reader.Name); break; case XmlNodeType.EndElement: result = string.Format("</{0}>", reader.Name); break; default: break; } return result; } } }
Delphi sample code
The Delphi sample code needs MSXML6.DLL (usually found in c:\WINDOWS\system32\msxml6.dll) imported as MSXML2_TLB.pas (in the Delphi IDE, choose the menu item “Components”, followed by “Import Component”, in the dialog choose “Import ActiveX component”, then select the right DLL, and import it into your project).
The code has been tested with Delphi 2007.
Delphi console apps
program DumpXml; {$APPTYPE CONSOLE} uses SysUtils, MSXML2_TLB in 'MSXML2_TLB.pas', XmlDumperUnit in 'XmlDumperUnit.pas'; var Index: Integer; begin try if ParamCount < 1 then Writeln('use parameters: [XmlFile]...') else for Index := 1 to ParamCount do with TXmlDumper.Create() do try Dump(ParamStr(Index)); Writeln(DumpResult); finally Free; end; except on E: Exception do Writeln(E.Classname, ': ', E.Message); end; end.
program ValidateXmlWithXsd; {$APPTYPE CONSOLE} uses SysUtils, XmlValidatorUnit in 'XmlValidatorUnit.pas', MSXML2_TLB in 'MSXML2_TLB.pas', XmlDumperUnit in 'XmlDumperUnit.pas'; begin try if ParamCount <> 2 then begin Writeln('use two parameters: XmlFile and XsdFile'); end else begin with TXmlValidator.Create do try if ValidateXml(ParamStr(1), ParamStr(2)) then Writeln('OK') else begin Writeln(ValidationResult); end; finally Free; end; with TXmlDumper.Create() do try Dump(ParamStr(1)); Writeln(DumpResult); finally Free; end; end; except on E: Exception do Writeln(E.Classname, ': ', E.Message); end; end.
Delphi supporting classes
unit XmlDumperUnit; interface uses Classes, MSXML2_TLB, ActiveX; type TXmlDumper = class(TObject) strict private FDumpStrings: TStrings; strict protected function GetHaveDump: Boolean; virtual; function GetDumpResult: string; virtual; function GetDumpStrings: TStrings; virtual; procedure DumpNode(const Node: IXMLDOMNode); virtual; property HaveDump: Boolean read GetHaveDump; property DumpStrings: TStrings read GetDumpStrings; public destructor Destroy; override; function Dump(const XmlFileName: string): Boolean; overload; virtual; function Dump(const XmlFileNames: array of string): Boolean; overload; virtual; property DumpResult: string read GetDumpResult; end; implementation uses SysUtils, Variants, ComObj; destructor TXmlDumper.Destroy; begin inherited; FreeAndNil(FDumpStrings); end; function TXmlDumper.GetDumpResult: string; begin if HaveDump then Result := DumpStrings.Text else Result := NullAsStringValue; end; function TXmlDumper.Dump(const XmlFileName: string): Boolean; var MultipleErrorMessages: Boolean; XmlDocument: IXMLDOMDocument3; node: IXMLDOMNode; begin XmlDocument := CoFreeThreadedDOMDocument60.Create(); if not Assigned(XmlDocument) then RaiseLastOSError(); if not XmlDocument.load(XmlFileName) then RaiseLastOSError(); node := XmlDocument.documentElement; DumpNode(node); Result := not HaveDump; end; function CharCount(const S: string; const CharToCount: Char): Integer; var Ch: Char; begin Result := 0; for Ch in S do begin if Ch = CharToCount then Inc(Result); end; end; function TXmlDumper.Dump(const XmlFileNames: array of string): Boolean; var XmlFileName: string; begin for XmlFileName in XmlFileNames do Dump(XmlFileName); Result := not HaveDump; end; procedure TXmlDumper.DumpNode(const Node: IXMLDOMNode); var attribute: IXMLDOMNode; attributes: IXMLDOMNamedNodeMap; childNode: IXMLDOMNode; childNodes: IXMLDOMNodeList; HaveNodeValue: Boolean; NodeType: DOMNodeType; NodeValue: OleVariant; NodeValueLength: Integer; NodeValueString: string; LfCount: Integer; CrCount: Integer; begin if not Assigned(Node) then Exit; NodeType := Node.NodeType; NodeValue := Node.nodeValue; HaveNodeValue := not VarIsNull(NodeValue); DumpStrings.Add(Format('NodeType=%d, NodeName=%s', [NodeType, Node.nodeName])); if HaveNodeValue then begin NodeValueString := NodeValue; NodeValueLength := Length(NodeValueString); LfCount := CharCount(NodeValueString, #10); CrCount := CharCount(NodeValueString, #13); DumpStrings.Add(Format('NodeValue-Length=%d, LF-Count=%d, CR-Count=%d', [NodeValueLength, LfCount, CrCount])); DumpStrings.Add(NodeValueString); end; attributes := Node.attributes; if Assigned(attributes) then begin attribute := attributes.nextNode; if Assigned(attribute) then repeat DumpNode(attribute); attribute := attributes.nextNode; until not Assigned(attribute); end; childNodes := Node.childNodes; if Assigned(childNodes) then begin childNode := childNodes.nextNode; if Assigned(childNode) then repeat DumpNode(childNode); childNode := childNodes.nextNode; until not Assigned(childNode); end; end; function TXmlDumper.GetHaveDump: Boolean; begin Result := Assigned(FDumpStrings) end; function TXmlDumper.GetDumpStrings: TStrings; begin if not HaveDump then FDumpStrings := TStringList.Create(); Result := FDumpStrings; end; initialization // http://chrisbensen.blogspot.com/2007/06/delphi-tips-and-tricks_26.html if Assigned(ComObj.CoInitializeEx) then ComObj.CoInitializeEx(nil, COINIT_MULTITHREADED) else CoInitialize(nil); finalization CoUninitialize; end.
unit XmlValidatorUnit; interface uses MSXML2_TLB, Classes; type /// loosely based on http://msdn.microsoft.com/en-us/library/ms765386(VS.85).aspx /// and http://www.nonhostile.com/howto-validate-xml-xsd-in-vb6.asp TXmlValidator = class(TObject) strict private FValidationResultStrings: TStrings; strict protected function GetHaveValidationResult: Boolean; virtual; function GetValidationResult: string; virtual; function GetValidationResultStrings: TStrings; virtual; procedure ProcessParseError(const parseError: IXMLDOMParseError); virtual; procedure ProcessParseError2(const parseError2: IXMLDOMParseError2); virtual; property HaveValidationResult: Boolean read GetHaveValidationResult; property ValidationResultStrings: TStrings read GetValidationResultStrings; public destructor Destroy; override; function ValidateXml(const XmlFileName, XsdFileName: string): Boolean; overload; function ValidateXml(const XmlFileName: string; const XsdFileNames: array of string): Boolean; overload; property ValidationResult: string read GetValidationResult; end; implementation uses Variants, SysUtils, ActiveX, ComObj; destructor TXmlValidator.Destroy; begin inherited; FreeAndNil(FValidationResultStrings); end; function TXmlValidator.GetHaveValidationResult: Boolean; begin Result := Assigned(FValidationResultStrings) end; function TXmlValidator.GetValidationResult: string; begin if HaveValidationResult then Result := ValidationResultStrings.Text else Result := NullAsStringValue; end; function TXmlValidator.GetValidationResultStrings: TStrings; begin if not HaveValidationResult then FValidationResultStrings := TStringList.Create(); Result := FValidationResultStrings; end; procedure TXmlValidator.ProcessParseError(const parseError: IXMLDOMParseError); var Reason: WideString; begin Reason := parseError.reason; // parseError.errorCode; // parseError.url; if Reason <> NullAsStringValue then ValidationResultStrings.Add(Reason); // parseError.srcText; end; procedure TXmlValidator.ProcessParseError2(const parseError2: IXMLDOMParseError2); var ParseErrorCollection: IXMLDOMParseErrorCollection; item: IXMLDOMParseError2; begin ProcessParseError(parseError2); ParseErrorCollection := parseError2.allErrors; if Assigned(ParseErrorCollection) then begin item := ParseErrorCollection.Next; if Assigned(item) then repeat ProcessParseError(item); item := ParseErrorCollection.Next; until not Assigned(item); end; end; function TXmlValidator.ValidateXml(const XmlFileName, XsdFileName: string): Boolean; begin Result := ValidateXml(XmlFileName, [XsdFileName]); end; function TXmlValidator.ValidateXml(const XmlFileName: string; const XsdFileNames: array of string): Boolean; var MultipleErrorMessages: Boolean; XmlDocument: IXMLDOMDocument3; XsdDocument: IXMLDOMDocument3; SchemaCollection: IXMLDOMSchemaCollection2; targetNamespaceNode: IXMLDOMNode; namespaceURI: string; XsdFileName: string; parseError: IXMLDOMParseError; parseError2: IXMLDOMParseError2; begin XmlDocument := CoFreeThreadedDOMDocument60.Create(); if not Assigned(XmlDocument) then RaiseLastOSError(); if not XmlDocument.load(XmlFileName) then RaiseLastOSError(); SchemaCollection := CoXMLSchemaCache60.Create(); if not Assigned(SchemaCollection) then RaiseLastOSError(); for XsdFileName in XsdFileNames do begin XsdDocument := CoFreeThreadedDOMDocument60.Create(); if not Assigned(XsdDocument) then RaiseLastOSError(); if not XsdDocument.load(XsdFileName) then RaiseLastOSError(); targetNamespaceNode := XsdDocument.documentElement.attributes.getNamedItem('targetNamespace'); if Assigned(targetNamespaceNode) then namespaceURI := targetNamespaceNode.nodeValue else namespaceURI := NullAsStringValue; SchemaCollection.Add(namespaceURI, XsdDocument); end; XmlDocument.schemas := SchemaCollection; parseError := XmlDocument.validate(); if Supports(parseError, IXMLDOMParseError2, parseError2) then ProcessParseError2(parseError2) else ProcessParseError(parseError); Result := not HaveValidationResult; end; initialization // http://chrisbensen.blogspot.com/2007/06/delphi-tips-and-tricks_26.html if Assigned(ComObj.CoInitializeEx) then ComObj.CoInitializeEx(nil, COINIT_MULTITHREADED) else CoInitialize(nil); finalization CoUninitialize; end.
Let me know with these samples
Have fun with the sources and let me know.
–jeroen
François said
Thanks Jeroen! Very interesting.
Quite a coincidence with the latest coding Horror: http://www.codinghorror.com/blog/archives/001319.html
jpluimers said
Indeed. Ain’t all these line-ending issues a great déjà-vu :-)
–jeroen
Blaz said
Thank you for the code!
Components4Developers
http://www.components4developers.com
The best SOA, ESB and EAI components for the best developers.
jpluimers said
Thanks!
It still is odd for me to see the SOA acronym, in Dutch it usually has nothing to do with computing.
–jeroen