Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/Fallout.Common/IO/XmlElementBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System;
using System.Xml.Linq;

namespace Fallout.Common.IO;

/// <summary>
/// A builder for creating <see cref="XElement"/> structures fluently.
/// </summary>
public class XmlElementBuilder(string name)
{
private readonly XElement element = new(name);

public XmlElementBuilder SetAttribute(string name, object value)
{
var attribute = element.Attribute(name);
if (attribute == null)
{
if (value != null)
element.Add(new XAttribute(name, value));
}
else
{
if (value == null)
attribute.Remove();
else
attribute.SetValue(value);
}
return this;
}

public XmlElementBuilder SetValue(object value)
{
element.SetValue(value);
return this;
}

public XmlElementBuilder AddChild(string name, Action<XmlElementBuilder> configurator = null)
{
var childBuilder = new XmlElementBuilder(name);
configurator?.Invoke(childBuilder);
element.Add(childBuilder.Build());
return this;
}

public XmlElementBuilder AddChild(XElement element)
{
this.element.Add(element);
return this;
}

public XElement Build()
{
return element;
}

public override string ToString()
{
return element.ToString();
}

public static implicit operator XElement(XmlElementBuilder builder)
{
return builder.Build();
}
}
74 changes: 67 additions & 7 deletions src/Fallout.Common/IO/XmlTasks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,20 @@ public static IEnumerable<string> XmlPeek(string path, string xpath, params (str
return XmlPeek(XDocument.Load(path), xpath, namespaces);
}

public static IEnumerable<string> XmlPeekFromString(string content, string xpath, params (string prefix, string uri)[] namespaces)
public static IEnumerable<string> XmlPeekFromString(string content, string xpath,
params (string prefix, string uri)[] namespaces)
{
return XmlPeek(XDocument.Parse(content), xpath, namespaces);
}

public static IEnumerable<XElement> XmlPeekElements(string path, string xpath, params (string prefix, string uri)[] namespaces)
public static IEnumerable<XElement> XmlPeekElements(string path, string xpath,
params (string prefix, string uri)[] namespaces)
{
return XmlPeekElements(XDocument.Load(path), xpath, namespaces);
}

public static IEnumerable<XElement> XmlPeekElementsFromString(string content, string xpath, params (string prefix, string uri)[] namespaces)
public static IEnumerable<XElement> XmlPeekElementsFromString(string content, string xpath,
params (string prefix, string uri)[] namespaces)
{
return XmlPeekElements(XDocument.Parse(content), xpath, namespaces);
}
Expand All @@ -46,7 +49,8 @@ public static void XmlPoke(string path, string xpath, object value, params (stri
XmlPoke(path, xpath, value, Encoding.UTF8, namespaces);
}

public static void XmlPoke(string path, string xpath, object value, Encoding encoding, params (string prefix, string uri)[] namespaces)
public static void XmlPoke(string path, string xpath, object value, Encoding encoding,
params (string prefix, string uri)[] namespaces)
{
var document = XDocument.Load(path, LoadOptions.PreserveWhitespace);
var (elements, attributes) = GetObjects(document, xpath, namespaces);
Expand All @@ -55,7 +59,62 @@ public static void XmlPoke(string path, string xpath, object value, Encoding enc
elements.SingleOrDefault()?.SetValue(value);
attributes.SingleOrDefault()?.SetValue(value);

var writerSettings = new XmlWriterSettings { OmitXmlDeclaration = document.Declaration == null, Encoding = encoding };
Save(document, path, encoding);
}

/// <summary>
/// Adds a new element or content to the XML structure at the specified XPath.
/// </summary>
public static void XmlAdd(string path, string xpath, object content, params (string prefix, string uri)[] namespaces)
{
XmlAdd(path, xpath, content, Encoding.UTF8, namespaces);
}

/// <summary>
/// Adds a new element or content to the XML structure at the specified XPath.
/// </summary>
public static void XmlAdd(string path, string xpath, object content, Encoding encoding,
params (string prefix, string uri)[] namespaces)
{
var document = XDocument.Load(path, LoadOptions.None);
var (elements, attributes) = GetObjects(document, xpath, namespaces);

var suffix = "(the element which should be parent of the newly added one)";
Assert.True(attributes.Count == 0,
$"XPath cannot select an attribute, because it must select exactly one element {suffix}.");

Assert.True(elements.Count == 1,
$"XPath '{xpath}' must select exactly one element {suffix}.");

var element = elements.Single();

var newContent = ContentToObject(content);
element.Add(newContent);

Save(document, path, encoding);
return;

XObject ContentToObject(object c)
{
return c switch
{
XmlElementBuilder builder => builder.Build(),
XObject xObject => xObject,
string stringContent when stringContent.TrimStart().StartsWith('<') => XElement.Parse(stringContent),
_ => new XText(c.ToString() ?? "")
};
}
}

private static void Save(XDocument document, string path, Encoding encoding)
{
var writerSettings = new XmlWriterSettings
{
OmitXmlDeclaration = document.Declaration == null,
Encoding = encoding,
Indent = true
};

using var xmlWriter = XmlWriter.Create(path, writerSettings);
document.Save(xmlWriter);
}
Expand All @@ -67,7 +126,8 @@ private static IEnumerable<string> XmlPeek(XDocument document, string xpath, (st
return elements.Count != 0 ? elements.Select(x => x.Value) : attributes.Select(x => x.Value);
}

private static IEnumerable<XElement> XmlPeekElements(XDocument document, string xpath, (string prefix, string uri)[] namespaces)
private static IEnumerable<XElement> XmlPeekElements(XDocument document, string xpath,
(string prefix, string uri)[] namespaces)
{
var (elements, attributes) = GetObjects(document, xpath, namespaces);
Assert.True(elements.Count == 0 || attributes.Count == 0);
Expand Down Expand Up @@ -99,7 +159,7 @@ private static (IReadOnlyCollection<XElement> Elements, IReadOnlyCollection<XAtt
}
}

var objects = ((IEnumerable) document.XPathEvaluate(xpath, xmlNamespaceManager)).Cast<XObject>().ToList();
var objects = ((IEnumerable)document.XPathEvaluate(xpath, xmlNamespaceManager)).Cast<XObject>().ToList();
return (objects.OfType<XElement>().ToList().AsReadOnly(),
objects.OfType<XAttribute>().ToList().AsReadOnly());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
<add key="local" value="../my-local-intermediate-nuget-feed" allowInsecureConnections="true" />
</packageSources>
<packageSourceMapping>
<clear />
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<root>
<child>value</child>
<new>element</new>
</root>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<root>
<child>value</child>
<new>element</new>
</root>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<root attr="val">
<child>inner</child>
</root>
109 changes: 104 additions & 5 deletions tests/Fallout.Common.Specs/XmlTasksSpecs.cs
Original file line number Diff line number Diff line change
@@ -1,22 +1,32 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Xml;
using Fallout.Common.IO;
using FluentAssertions;
using Fallout.Common.IO;
using VerifyXunit;
using Xunit;

namespace Fallout.Common.Specs;

public class XmlTasksSpecs
public class XmlTasksSpecs : IDisposable
{
private readonly string _tempFile = Path.GetTempFileName();

public void Dispose()
{
if (File.Exists(_tempFile))
File.Delete(_tempFile);
}

[Fact]
public void Loading_from_xml_string_works()
{
var content = @"<root><element>value</element></root>";
var elements = XmlTasks.XmlPeekElementsFromString(content, "/root/element").ToList();
var elements = XmlTasks.XmlPeekElementsFromString(NugetConfig, "/configuration/packageSources").ToList();

elements.Should().HaveCount(1);
elements.Single().Value.Should().Be("value");
elements.Single().Elements().Should().HaveCount(2);
}

[Fact]
Expand All @@ -38,4 +48,93 @@ public void Loading_from_url_throws()

action.Should().Throw<XmlException>();
}

[Fact]
public async Task Adding_element_via_builder_updates_xml_file()
{
await File.WriteAllTextAsync(_tempFile, @"<root><child>value</child></root>");

XmlTasks.XmlAdd(_tempFile, "/root", new XmlElementBuilder("new").SetValue("element"));

var content = await File.ReadAllTextAsync(_tempFile);
await Verifier.Verify(content, "xml");
}

[Fact]
public async Task Adding_element_via_xml_string_updates_xml_file()
{
await File.WriteAllTextAsync(_tempFile, @"<root><child>value</child></root>");

XmlTasks.XmlAdd(_tempFile, "/root", "<new>element</new>");

var content = await File.ReadAllTextAsync(_tempFile);
await Verifier.Verify(content, "xml");
}

[Fact]
public async Task Fluent_api_builds_expected_xml_structure()
{
var builder = new XmlElementBuilder("root")
.SetAttribute("attr", "val")
.AddChild("child", c => c.SetValue("inner"));

var element = builder.Build();
await Verifier.Verify(element);
}

[Fact]
public void Attributes_maintain_addition_order()
{
var builder = new XmlElementBuilder("root")
.SetAttribute("z", "1")
.SetAttribute("a", "2")
.SetAttribute("m", "3");

var element = builder.Build();
var attributes = element.Attributes().Select(a => a.Name.LocalName).ToList();

attributes.Should().ContainInOrder("z", "a", "m");
}

[Fact]
public void Setting_attribute_to_null_removes_it()
{
var element = new XmlElementBuilder("root")
.SetAttribute("a", "1")
.SetAttribute("a", null)
.Build();

element.Attribute("a").Should().BeNull();
}

[Fact]
public async Task A_real_world_use_case()
{
await File.WriteAllTextAsync(_tempFile, NugetConfig);

XmlTasks.XmlAdd(_tempFile, "/configuration/packageSources", new XmlElementBuilder("add")
.SetAttribute("key", "local")
.SetAttribute("value", "../my-local-intermediate-nuget-feed")
.SetAttribute("allowInsecureConnections", "true"));

var content = await File.ReadAllTextAsync(_tempFile);
await Verifier.Verify(content, "xml");
}

private static string NugetConfig =>
"""
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
<packageSourceMapping>
<clear />
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>
""";
}
Loading