diff --git a/ReqIFSharp.Tests/NamespaceRetentionTestFixture.cs b/ReqIFSharp.Tests/NamespaceRetentionTestFixture.cs new file mode 100644 index 0000000..22e096d --- /dev/null +++ b/ReqIFSharp.Tests/NamespaceRetentionTestFixture.cs @@ -0,0 +1,335 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2017-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ReqIFSharp.Tests +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.IO.Compression; + using System.Linq; + using System.Text; + using System.Threading; + using System.Xml; + + using NUnit.Framework; + + using ReqIFSharp; + + /// + /// Suite of tests that verify the namespace declarations and root attributes of a + /// document are retained when it is deserialized and serialized to a new destination (issue #44). + /// + [TestFixture] + public class NamespaceRetentionTestFixture + { + private const string ReqIFNamespace = "http://www.omg.org/spec/ReqIF/20110401/reqif.xsd"; + private const string XsiNamespace = "http://www.w3.org/2001/XMLSchema-instance"; + + private string datatypeDemoPath; + + [SetUp] + public void SetUp() + { + this.datatypeDemoPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "TestData", "Datatype-Demo.reqif"); + } + + [Test] + public void Verify_that_namespace_declarations_are_retained_on_roundtrip() + { + var deserializer = new ReqIFDeserializer(); + var serializer = new ReqIFSerializer(); + + var reqif = deserializer.Deserialize(this.datatypeDemoPath).First(); + + using var output = new MemoryStream(); + serializer.Serialize(new[] { reqif }, output, SupportedFileExtensionKind.Reqif); + + var attributes = ExtractRootAttributes(output); + + Assert.Multiple(() => + { + Assert.That(attributes.ContainsKey("xmlns"), Is.True, "the default ReqIF namespace declaration is missing"); + Assert.That(attributes["xmlns"], Is.EqualTo(ReqIFNamespace)); + Assert.That(attributes["xmlns:configuration"], Is.EqualTo("http://eclipse.org/rmf/pror/toolextensions/1.0")); + Assert.That(attributes["xmlns:id"], Is.EqualTo("http://pror.org/presentation/id")); + Assert.That(attributes["xmlns:xhtml"], Is.EqualTo("http://www.w3.org/1999/xhtml")); + }); + } + + [Test] + public async System.Threading.Tasks.Task Verify_that_namespace_declarations_are_retained_on_roundtrip_async() + { + var deserializer = new ReqIFDeserializer(); + var serializer = new ReqIFSerializer(); + + var reqif = (await deserializer.DeserializeAsync(this.datatypeDemoPath, CancellationToken.None)).First(); + + using var output = new MemoryStream(); + await serializer.SerializeAsync(new[] { reqif }, output, SupportedFileExtensionKind.Reqif, CancellationToken.None); + + var attributes = ExtractRootAttributes(output); + + Assert.Multiple(() => + { + Assert.That(attributes["xmlns"], Is.EqualTo(ReqIFNamespace)); + Assert.That(attributes["xmlns:configuration"], Is.EqualTo("http://eclipse.org/rmf/pror/toolextensions/1.0")); + Assert.That(attributes["xmlns:id"], Is.EqualTo("http://pror.org/presentation/id")); + Assert.That(attributes["xmlns:xhtml"], Is.EqualTo("http://www.w3.org/1999/xhtml")); + }); + } + + [Test] + public void Verify_that_prefixed_root_attribute_is_retained_on_roundtrip() + { + const string schemaLocation = "http://www.omg.org/spec/ReqIF/20110401/reqif.xsd reqif.xsd"; + + // start from a known-valid document and add an xsi namespace declaration plus an + // xsi:schemaLocation prefixed attribute to the REQ-IF root element + var xml = File.ReadAllText(this.datatypeDemoPath) + .Replace( + $"xmlns=\"{ReqIFNamespace}\"", + $"xmlns=\"{ReqIFNamespace}\" xmlns:xsi=\"{XsiNamespace}\" xsi:schemaLocation=\"{schemaLocation}\""); + + var deserializer = new ReqIFDeserializer(); + var serializer = new ReqIFSerializer(); + + ReqIF reqif; + using (var input = new MemoryStream(Encoding.UTF8.GetBytes(xml))) + { + reqif = deserializer.Deserialize(input, SupportedFileExtensionKind.Reqif).First(); + } + + using var output = new MemoryStream(); + serializer.Serialize(new[] { reqif }, output, SupportedFileExtensionKind.Reqif); + + var attributes = ExtractRootAttributes(output); + + Assert.Multiple(() => + { + Assert.That(attributes["xmlns:xsi"], Is.EqualTo(XsiNamespace), "the xsi namespace declaration was not retained"); + Assert.That(attributes["xsi:schemaLocation"], Is.EqualTo(schemaLocation), "the xsi:schemaLocation attribute was not retained"); + }); + } + + [Test] + public void Verify_that_repeated_serialization_does_not_duplicate_namespace_declarations() + { + var deserializer = new ReqIFDeserializer(); + var serializer = new ReqIFSerializer(); + + var reqif = deserializer.Deserialize(this.datatypeDemoPath).First(); + + // serialize twice from the same object; the second pass must not accumulate extra declarations + using (var first = new MemoryStream()) + { + serializer.Serialize(new[] { reqif }, first, SupportedFileExtensionKind.Reqif); + } + + using var second = new MemoryStream(); + serializer.Serialize(new[] { reqif }, second, SupportedFileExtensionKind.Reqif); + + var attributes = ExtractRootAttributes(second); + + Assert.That(attributes["xmlns:xhtml"], Is.EqualTo("http://www.w3.org/1999/xhtml")); + } + + [TestCase("Datatype-Demo.reqif")] + [TestCase("ProR_Traceability-Template-v1.0.reqif")] + [TestCase("DefaultValueDemo.reqif")] + [TestCase("reqifsharpgenerated.reqif")] + public void Verify_that_root_attributes_of_reqif_files_survive_roundtrip(string fileName) + { + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "TestData", fileName); + + IDictionary source; + using (var sourceStream = File.OpenRead(path)) + { + source = ExtractRootAttributes(sourceStream); + } + + var deserializer = new ReqIFDeserializer(); + var serializer = new ReqIFSerializer(); + + var reqif = deserializer.Deserialize(path).First(); + + using var output = new MemoryStream(); + serializer.Serialize(new[] { reqif }, output, SupportedFileExtensionKind.Reqif); + + var serialized = ExtractRootAttributes(output); + + AssertAttributesRetained(source, serialized, fileName); + } + + [TestCase("Spielwiese.reqifz", 1)] + [TestCase("requirements-and-objects.reqifz", 1)] + [TestCase("test-multiple-reqif.reqifz", 2)] + public void Verify_that_namespace_declarations_of_reqifz_archives_survive_roundtrip(string fileName, int expectedDocumentCount) + { + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "TestData", fileName); + + IDictionary sourceNamespaces; + using (var sourceStream = File.OpenRead(path)) + { + sourceNamespaces = UnionNamespaceDeclarations(ExtractArchiveRootAttributes(sourceStream)); + } + + var deserializer = new ReqIFDeserializer(); + var serializer = new ReqIFSerializer(); + + var reqifs = deserializer.Deserialize(path).ToList(); + Assert.That(reqifs, Has.Count.EqualTo(expectedDocumentCount)); + + using var output = new MemoryStream(); + serializer.Serialize(reqifs, output, SupportedFileExtensionKind.Reqifz); + + var outputDocuments = ExtractArchiveRootAttributes(output); + Assert.That(outputDocuments, Has.Count.EqualTo(expectedDocumentCount), $"{fileName}: the number of serialized documents changed"); + + var outputNamespaces = UnionNamespaceDeclarations(outputDocuments); + + AssertAttributesRetained(sourceNamespaces, outputNamespaces, fileName); + } + + [TestCase("Spielwiese.reqifz", 1)] + [TestCase("test-multiple-reqif.reqifz", 2)] + public async System.Threading.Tasks.Task Verify_that_namespace_declarations_of_reqifz_archives_survive_roundtrip_async(string fileName, int expectedDocumentCount) + { + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "TestData", fileName); + + IDictionary sourceNamespaces; + using (var sourceStream = File.OpenRead(path)) + { + sourceNamespaces = UnionNamespaceDeclarations(ExtractArchiveRootAttributes(sourceStream)); + } + + var deserializer = new ReqIFDeserializer(); + var serializer = new ReqIFSerializer(); + + var reqifs = (await deserializer.DeserializeAsync(path, CancellationToken.None)).ToList(); + Assert.That(reqifs, Has.Count.EqualTo(expectedDocumentCount)); + + using var output = new MemoryStream(); + await serializer.SerializeAsync(reqifs, output, SupportedFileExtensionKind.Reqifz, CancellationToken.None); + + var outputNamespaces = UnionNamespaceDeclarations(ExtractArchiveRootAttributes(output)); + + AssertAttributesRetained(sourceNamespaces, outputNamespaces, fileName); + } + + /// + /// Asserts that every attribute in is present in + /// with the same value (the serializer may add declarations such as the XHTML namespace, but must not drop any). + /// + private static void AssertAttributesRetained(IDictionary source, IDictionary serialized, string context) + { + Assert.Multiple(() => + { + foreach (var attribute in source) + { + Assert.That(serialized.ContainsKey(attribute.Key), Is.True, $"{context}: the '{attribute.Key}' attribute was not retained"); + Assert.That(serialized.TryGetValue(attribute.Key, out var value) ? value : null, Is.EqualTo(attribute.Value), $"{context}: the value of '{attribute.Key}' changed"); + } + }); + } + + /// + /// Reads the REQ-IF root attributes of every .reqif entry contained in a .reqifz archive. + /// + private static List> ExtractArchiveRootAttributes(Stream reqifzStream) + { + if (reqifzStream.CanSeek) + { + reqifzStream.Position = 0; + } + + var documents = new List>(); + + using var archive = new ZipArchive(reqifzStream, ZipArchiveMode.Read, leaveOpen: true); + + foreach (var entry in archive.Entries) + { + if (entry.FullName.EndsWith(".reqif", StringComparison.OrdinalIgnoreCase)) + { + using var entryStream = entry.Open(); + documents.Add(ExtractRootAttributes(entryStream)); + } + } + + return documents; + } + + /// + /// Collects the union of namespace declarations (xmlns / xmlns:prefix) across the supplied documents. + /// + private static IDictionary UnionNamespaceDeclarations(IEnumerable> documents) + { + var union = new Dictionary(); + + foreach (var document in documents) + { + foreach (var attribute in document) + { + if (attribute.Key == "xmlns" || attribute.Key.StartsWith("xmlns:", StringComparison.Ordinal)) + { + union[attribute.Key] = attribute.Value; + } + } + } + + return union; + } + + /// + /// Reads the attributes declared on the REQ-IF root element of a serialized document, keyed by + /// their qualified name (e.g. xmlns, xmlns:configuration, xsi:schemaLocation). + /// + private static IDictionary ExtractRootAttributes(Stream stream) + { + if (stream.CanSeek) + { + stream.Position = 0; + } + + var attributes = new Dictionary(); + + using var reader = XmlReader.Create(stream); + + while (reader.Read()) + { + if (reader.NodeType == XmlNodeType.Element && reader.Name == "REQ-IF") + { + if (reader.MoveToFirstAttribute()) + { + do + { + attributes[reader.Name] = reader.Value; + } + while (reader.MoveToNextAttribute()); + } + + break; + } + } + + return attributes; + } + } +} diff --git a/ReqIFSharp/DefaultXmlAttributeFactory.cs b/ReqIFSharp/DefaultXmlAttributeFactory.cs index 1275953..29a15c6 100644 --- a/ReqIFSharp/DefaultXmlAttributeFactory.cs +++ b/ReqIFSharp/DefaultXmlAttributeFactory.cs @@ -37,6 +37,11 @@ public static class DefaultXmlAttributeFactory /// public const string XHTMLNameSpaceUri = @"http://www.w3.org/1999/xhtml"; + /// + /// The reserved URI used by the XML parser for namespace declaration attributes (xmlns / xmlns:prefix) + /// + public const string XmlNamespaceDeclarationUri = @"http://www.w3.org/2000/xmlns/"; + /// /// Creates the XHTML namespace attribute in case the document /// contains XHTML data @@ -56,6 +61,7 @@ internal static XmlAttribute CreateXHTMLNameSpaceAttribute(ReqIF reqIf) { LocalName = "xhtml", Prefix = "xmlns", + NamespaceUri = XmlNamespaceDeclarationUri, Value = XHTMLNameSpaceUri }; diff --git a/ReqIFSharp/ReqIF.cs b/ReqIFSharp/ReqIF.cs index 1685ce0..a7bee2e 100644 --- a/ReqIFSharp/ReqIF.cs +++ b/ReqIFSharp/ReqIF.cs @@ -116,6 +116,7 @@ internal void ReadXml(XmlReader reader) { Prefix = reader.Prefix, LocalName = reader.LocalName, + NamespaceUri = reader.NamespaceURI, Value = reader.Value }; @@ -187,6 +188,7 @@ internal async Task ReadXmlAsync(XmlReader reader, CancellationToken token) { Prefix = reader.Prefix, LocalName = reader.LocalName, + NamespaceUri = reader.NamespaceURI, Value = reader.Value }; @@ -290,30 +292,22 @@ internal async Task WriteXmlAsync(XmlWriter writer, CancellationToken token) /// private void WriteNameSpaceAttributes(XmlWriter writer) { - if (attributes.TrueForAll(x => x.Value != DefaultXmlAttributeFactory.XHTMLNameSpaceUri)) + foreach (var xmlAttribute in this.QueryNameSpaceAttributes()) { - var xmlAttribute = DefaultXmlAttributeFactory.CreateXHTMLNameSpaceAttribute(this); - if (xmlAttribute != null) + if (xmlAttribute.Prefix == "xmlns") { - this.attributes.Add(xmlAttribute); + // prefixed namespace declaration: xmlns:prefix="uri" + writer.WriteAttributeString(xmlAttribute.Prefix, xmlAttribute.LocalName, null, xmlAttribute.Value); } - } - - foreach (var xmlAttribute in this.attributes) - { - if (xmlAttribute.Prefix != string.Empty) + else if (xmlAttribute.Prefix != string.Empty) { - if (xmlAttribute.Prefix == "xmlns") - { - writer.WriteAttributeString(xmlAttribute.Prefix, xmlAttribute.LocalName, null, xmlAttribute.Value); - } - else - { - writer.WriteAttributeString(xmlAttribute.LocalName, xmlAttribute.Prefix, xmlAttribute.Value); - } + // ordinary prefixed attribute (e.g. xsi:schemaLocation): the namespace URI the + // prefix is bound to is required to emit it faithfully + writer.WriteAttributeString(xmlAttribute.Prefix, xmlAttribute.LocalName, xmlAttribute.NamespaceUri, xmlAttribute.Value); } else { + // default namespace declaration (xmlns="...") or an unprefixed attribute writer.WriteAttributeString(xmlAttribute.LocalName, xmlAttribute.Value); } } @@ -327,35 +321,52 @@ private void WriteNameSpaceAttributes(XmlWriter writer) /// private async Task WriteNameSpaceAttributesAsync(XmlWriter writer) { - if (attributes.TrueForAll(x => x.Value != DefaultXmlAttributeFactory.XHTMLNameSpaceUri)) + foreach (var xmlAttribute in this.QueryNameSpaceAttributes()) { - var xmlAttribute = DefaultXmlAttributeFactory.CreateXHTMLNameSpaceAttribute(this); - if (xmlAttribute != null) + if (xmlAttribute.Prefix == "xmlns") { - this.attributes.Add(xmlAttribute); + // prefixed namespace declaration: xmlns:prefix="uri" + await writer.WriteAttributeStringAsync(xmlAttribute.Prefix, xmlAttribute.LocalName, null, xmlAttribute.Value); } - } - - foreach (var xmlAttribute in this.attributes) - { - if (xmlAttribute.Prefix != string.Empty) + else if (xmlAttribute.Prefix != string.Empty) { - if (xmlAttribute.Prefix == "xmlns") - { - await writer.WriteAttributeStringAsync(xmlAttribute.Prefix, xmlAttribute.LocalName, null, xmlAttribute.Value); - } - else - { - await writer.WriteAttributeStringAsync(null, xmlAttribute.LocalName, xmlAttribute.Prefix, xmlAttribute.Value); - } + // ordinary prefixed attribute (e.g. xsi:schemaLocation): the namespace URI the + // prefix is bound to is required to emit it faithfully + await writer.WriteAttributeStringAsync(xmlAttribute.Prefix, xmlAttribute.LocalName, xmlAttribute.NamespaceUri, xmlAttribute.Value); } else { + // default namespace declaration (xmlns="...") or an unprefixed attribute await writer.WriteAttributeStringAsync(null, xmlAttribute.LocalName, null, xmlAttribute.Value); } } } + /// + /// Gets the namespace attributes that are to be written to the REQ-IF XML element: the attributes + /// captured when the document was read, plus the XHTML namespace declaration when the document + /// contains XHTML data and does not already declare it. + /// + /// + /// the to serialize; the captured + /// are not mutated so the can be serialized more than once. + /// + private IEnumerable QueryNameSpaceAttributes() + { + var namespaceAttributes = new List(this.attributes); + + if (namespaceAttributes.TrueForAll(x => x.Value != DefaultXmlAttributeFactory.XHTMLNameSpaceUri)) + { + var xmlAttribute = DefaultXmlAttributeFactory.CreateXHTMLNameSpaceAttribute(this); + if (xmlAttribute != null) + { + namespaceAttributes.Add(xmlAttribute); + } + } + + return namespaceAttributes; + } + /// /// Writes the /// diff --git a/ReqIFSharp/ReqIFSerializer.cs b/ReqIFSharp/ReqIFSerializer.cs index b6084cd..3aab4d9 100644 --- a/ReqIFSharp/ReqIFSerializer.cs +++ b/ReqIFSharp/ReqIFSerializer.cs @@ -50,7 +50,7 @@ public void Serialize(IEnumerable reqifs, string fileUri) var fileExtensionKind = fileUri.ConvertPathToSupportedFileExtensionKind(); - using (var fileStream = new FileStream(fileUri, FileMode.OpenOrCreate)) + using (var fileStream = new FileStream(fileUri, FileMode.Create)) { this.Serialize(reqifs, fileStream, fileExtensionKind); } @@ -121,7 +121,7 @@ public async Task SerializeAsync(IEnumerable reqifs, string fileUri, Canc var fileExtensionKind = fileUri.ConvertPathToSupportedFileExtensionKind(); - using (var fileStream = new FileStream(fileUri, FileMode.OpenOrCreate)) + using (var fileStream = new FileStream(fileUri, FileMode.Create)) { await this.SerializeAsync(reqifs, fileStream, fileExtensionKind, token); } diff --git a/ReqIFSharp/XmlAttribute.cs b/ReqIFSharp/XmlAttribute.cs index de275e5..da87b84 100644 --- a/ReqIFSharp/XmlAttribute.cs +++ b/ReqIFSharp/XmlAttribute.cs @@ -35,6 +35,17 @@ internal class XmlAttribute /// internal string LocalName { get; set; } + /// + /// Gets or sets the namespace URI of the attribute. + /// + /// + /// For namespace declarations (xmlns / xmlns:prefix) this is the reserved + /// http://www.w3.org/2000/xmlns/ URI; for ordinary prefixed attributes (e.g. + /// xsi:schemaLocation) it is the URI the prefix is bound to. Captured on read so the + /// attribute can be re-emitted faithfully. + /// + internal string NamespaceUri { get; set; } + /// /// Gets or sets the value of the attribute. ///