Skip to content
Closed
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
125 changes: 102 additions & 23 deletions src/RestSharp.Serializers.Xml/XmlDeserializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,16 @@ public class XmlDeserializer : IXmlDeserializer, IWithRootElement, IWithDateForm
var root = doc.Root;
var rootElement = response.RootElement ?? RootElement;

if (rootElement != null && doc.Root != null)
root = doc.Root.DescendantsAndSelf(rootElement.AsNamespaced(Namespace)).SingleOrDefault();
if (rootElement != null && doc.Root != null) {
var namespacedRoot = rootElement.AsNamespaced(Namespace);
// Prefer shallowest match to avoid nested elements with same name
root = namespacedRoot != null
? doc.Root.Element(namespacedRoot)
?? doc.Root.DescendantsAndSelf(namespacedRoot)
.OrderBy(e => e.Ancestors().Count())
.FirstOrDefault()
: null;
}

// autodetect xml namespace
if (Namespace.IsEmpty())
Expand Down Expand Up @@ -76,10 +84,16 @@ static void RemoveNamespace(XDocument xdoc) {
? new(XNamespace.None.GetName(a.Name.LocalName), a.Value)
: a
)
.Where(a => a != null)
);
}
}

static bool IsValidXmlElementName(string name) {
// Generic type names contain backtick (e.g., "List`1") which is invalid in XML element names
return !name.Contains('`');
}

protected virtual object Map(object x, XElement? root) {
var objType = x.GetType();
var props = objType.GetProperties();
Expand Down Expand Up @@ -236,7 +250,20 @@ protected virtual object Map(object x, XElement? root) {
}
else if (type.IsGenericType) {
var list = (IList)Activator.CreateInstance(asType)!;
var container = GetElementByName(root, name);
XElement? container = null;

// First check if root itself is the container (matches property name)
if (root != null && name?.LocalName != null) {
var rootName = root.Name.LocalName;
if (rootName.Equals(name.LocalName, StringComparison.OrdinalIgnoreCase)) {
container = root;
}
}

// If root is not the container, try to find it as a child element
if (container == null) {
container = GetElementByName(root, name);
}

if (container?.HasElements == true) {
var first = container.Elements().FirstOrDefault();
Expand Down Expand Up @@ -306,37 +333,89 @@ object HandleListDerivative(XElement root, string propName, Type type) {

var list = (IList)Activator.CreateInstance(type)!;

IList<XElement> elements = root.Descendants(t.Name.AsNamespaced(Namespace)).ToList();

var name = t.Name;
var attribute = t.GetAttribute<DeserializeAsAttribute>();

if (attribute != null)
name = attribute.Name;

if (!elements.Any()) {
var lowerName = name?.ToLower(Culture).AsNamespaced(Namespace);

elements = root.Descendants(lowerName).ToList();
// Try to find a container element first using property name
XElement? container = null;

// Try property name first (skip if it contains invalid XML name characters like ` in generic types)
if (IsValidXmlElementName(propName)) {
container = GetElementByName(root, propName.AsNamespaced(Namespace));
}

if (!elements.Any()) {
var camelName = name?.ToCamelCase(Culture).AsNamespaced(Namespace);

elements = root.Descendants(camelName).ToList();

// Check if root itself matches the container naming
if (container == null && IsValidXmlElementName(propName)) {
var rootName = root.Name.LocalName;
var propNameLower = propName.ToLower(Culture);

if (rootName.Equals(propName, StringComparison.OrdinalIgnoreCase) ||
rootName.Equals(propNameLower, StringComparison.OrdinalIgnoreCase)) {
container = root;
}
}

IList<XElement> elements;

// If we found a container, get only its direct children (Elements)
if (container != null && container.HasElements) {
// Try to match item elements by name variations
var itemName = t.Name.AsNamespaced(Namespace);
var directChildren = container.Elements(itemName).ToList();

if (!directChildren.Any()) {
var lowerName = name?.ToLower(Culture).AsNamespaced(Namespace);
directChildren = container.Elements(lowerName).ToList();
}

if (!directChildren.Any()) {
var camelName = name?.ToCamelCase(Culture).AsNamespaced(Namespace);
directChildren = container.Elements(camelName).ToList();
}

if (!directChildren.Any()) {
directChildren = container.Elements()
.Where(e => e.Name.LocalName.RemoveUnderscoresAndDashes() == name)
.ToList();
}

if (!directChildren.Any()) {
var lowerName = name?.ToLower(Culture);
directChildren = container.Elements()
.Where(e => e.Name.LocalName.RemoveUnderscoresAndDashes() == lowerName)
.ToList();
}

elements = directChildren;
}
else {
// Fallback: No container found, use Descendants for backward compatibility
elements = root.Descendants(t.Name.AsNamespaced(Namespace)).ToList();

if (!elements.Any())
elements = root.Descendants()
.Where(e => e.Name.LocalName.RemoveUnderscoresAndDashes() == name)
.ToList();
if (!elements.Any()) {
var lowerName = name?.ToLower(Culture).AsNamespaced(Namespace);
elements = root.Descendants(lowerName).ToList();
}

if (!elements.Any()) {
var lowerName = name?.ToLower(Culture).AsNamespaced(Namespace);
if (!elements.Any()) {
var camelName = name?.ToCamelCase(Culture).AsNamespaced(Namespace);
elements = root.Descendants(camelName).ToList();
}

elements = root.Descendants()
.Where(e => e.Name.LocalName.RemoveUnderscoresAndDashes() == lowerName)
.ToList();
if (!elements.Any())
elements = root.Descendants()
.Where(e => e.Name.LocalName.RemoveUnderscoresAndDashes() == name)
.ToList();

if (!elements.Any()) {
var lowerName = name?.ToLower(Culture);
elements = root.Descendants()
.Where(e => e.Name.LocalName.RemoveUnderscoresAndDashes() == lowerName)
.ToList();
}
}

PopulateListFromElements(t, elements, list);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace RestSharp.Tests.Serializers.Xml.SampleClasses;

// Test classes for nested element bugs

public class Item {
public int Id { get; set; }
public List<Item> SubItems { get; set; }
}

public class ItemContainer {
public List<Item> Items { get; set; } = new();
}

public class ItemWithGroup {
public int Id { get; set; }
public ItemGroup Group { get; set; }
}

public class ItemGroup {
public List<Item> Items { get; set; }
}

public class ItemsResponse {
public List<ItemWithGroup> Items { get; set; } = new();
}
166 changes: 165 additions & 1 deletion test/RestSharp.Tests.Serializers.Xml/XmlDeserializerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1076,4 +1076,168 @@ public void Ignore_ReadOnly_Property_That_Exists_In_Data() {

Assert.Null(p.ReadOnlyProxy);
}
}

[Fact]
public void Deserialize_Nested_List_Should_Not_Include_Deeply_Nested_Items() {
// Bug #1 test: HandleListDerivative should use Elements() on containers, not Descendants()
const string xml = """
<root>
<items>
<item>
<id>1</id>
<subitems>
<item><id>2</id></item>
<item><id>3</id></item>
</subitems>
</item>
</items>
</root>
""";

var deserializer = new XmlDeserializer();
var result = deserializer.Deserialize<ItemContainer>(new RestResponse { Content = xml })!;

// Should only have 1 item (id=1), not 3 (id=1,2,3)
Assert.NotNull(result);
Assert.NotNull(result.Items);
Assert.Single(result.Items);
Assert.Equal(1, result.Items[0].Id);
}

[Fact]
public void Deserialize_RootElement_Should_Not_Throw_On_Duplicate_Nested_Names() {
// Bug #2 test: RootElement selection should handle duplicate names gracefully
const string xml = """
<root>
<items>
<item>
<id>72</id>
<group>
<items>
<item><id>74</id></item>
</items>
</group>
</item>
</items>
</root>
""";

var deserializer = new XmlDeserializer { RootElement = "items" };

// Should not throw InvalidOperationException: Sequence contains more than one element
var exception = Record.Exception(() =>
deserializer.Deserialize<ItemsResponse>(new RestResponse { Content = xml })
);

Assert.Null(exception);
}

[Fact]
public void Deserialize_RootElement_Should_Prefer_Shallowest_Match() {
// Bug #2 test: When multiple elements match RootElement, prefer the shallowest one
const string xml = """
<root>
<items>
<item>
<id>72</id>
<group>
<items>
<item><id>74</id></item>
</items>
</group>
</item>
</items>
</root>
""";

var deserializer = new XmlDeserializer { RootElement = "items" };
var result = deserializer.Deserialize<ItemsResponse>(new RestResponse { Content = xml })!;

// Should deserialize from the top-level <items>, not the nested one
Assert.NotNull(result);
Assert.NotNull(result.Items);
Assert.Single(result.Items);
Assert.Equal(72, result.Items[0].Id);
}

[Fact]
public void Deserialize_RootElement_Should_Try_Direct_Child_First() {
// Bug #2 test: Should try Element() before DescendantsAndSelf()
const string xml = """
<root>
<data>
<one>direct</one>
</data>
</root>
""";

var deserializer = new XmlDeserializer { RootElement = "data" };
var result = deserializer.Deserialize<SimpleStruct>(new RestResponse { Content = xml });

Assert.Equal("direct", result.One);
}

[Fact]
public void Deserialize_List_With_Container_Should_Use_Direct_Children_Only() {
// Test that container-based list deserialization uses Elements() not Descendants()
const string xml = """
<root>
<items>
<item>
<id>10</id>
<subitems>
<item><id>20</id></item>
</subitems>
</item>
<item>
<id>11</id>
</item>
</items>
</root>
""";

var deserializer = new XmlDeserializer();
var result = deserializer.Deserialize<ItemContainer>(new RestResponse { Content = xml })!;

Assert.NotNull(result.Items);
Assert.Equal(2, result.Items.Count);
Assert.Equal(10, result.Items[0].Id);
Assert.Equal(11, result.Items[1].Id);
}

[Fact]
public void Deserialize_Nested_Items_Should_Also_Use_Direct_Children() {
// Test that nested subitems also correctly use direct children
const string xml = """
<root>
<items>
<item>
<id>1</id>
<subitems>
<item><id>2</id></item>
<item>
<id>3</id>
<subitems>
<item><id>4</id></item>
</subitems>
</item>
</subitems>
</item>
</items>
</root>
""";

var deserializer = new XmlDeserializer();
var result = deserializer.Deserialize<ItemContainer>(new RestResponse { Content = xml })!;

Assert.NotNull(result.Items);
Assert.Single(result.Items);

var topItem = result.Items[0];
Assert.Equal(1, topItem.Id);
Assert.NotNull(topItem.SubItems);
Assert.Equal(2, topItem.SubItems.Count);
Assert.Equal(2, topItem.SubItems[0].Id);
Assert.Equal(3, topItem.SubItems[1].Id);
}
}
Loading