Skip to content

Instantly share code, notes, and snippets.

@kevinoid
Created August 12, 2018 22:49
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kevinoid/a081de610b58ef66692fb04a7d8dfb22 to your computer and use it in GitHub Desktop.
Save kevinoid/a081de610b58ef66692fb04a7d8dfb22 to your computer and use it in GitHub Desktop.
.NET JSON deserializer for collection types which supports preserving order

.NET JSON Collection Deserializer

This gist is a JSON deserializer built on top of JsonReaderWriterFactory as described in the WCF Weakly-typed JSON Serialization Sample. It deserializes to IList and IDictionary types chosen by the caller.

My primary motivation in writing it was to support deserializing to a type which can preserve object property order.

Given that this implementation is longer than the JavaScriptReader implementation which ships with System.Json in .NET Core, an implementation based on that version is likely preferable to this one.

// <copyright file="JsonCollectionDeserializer.cs" company="Kevin Locke">
// Copyright 2018 Kevin Locke
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
// </copyright>
namespace KevinLocke.Json
{
using System;
using System.Collections;
using System.Collections.Specialized;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Runtime.Serialization.Json;
using System.Text;
using System.Xml;
public class JsonCollectionDeserializer
{
/// <summary>
/// Initializes a new instance of the
/// <see cref="JsonCollectionDeserializer"/> class using the default
/// collection type factories.
/// </summary>
public JsonCollectionDeserializer()
: this(null, null)
{
}
/// <summary>
/// Initializes a new instance of the
/// <see cref="JsonCollectionDeserializer"/> class using the given
/// collection type factories.
/// </summary>
/// <param name="arrayFactory">Factory function for <see cref="IList"/>
/// instances which will hold JSON array values.</param>
/// <param name="objectFactory">Factory function for <see cref="IDictionary"/>
/// instances which will hold JSON object values.</param>
public JsonCollectionDeserializer(Func<IList> arrayFactory, Func<IDictionary> objectFactory)
{
this.ArrayFactory = arrayFactory ?? (() => new ArrayList());
this.ObjectFactory = objectFactory ?? (() => new OrderedDictionary());
}
/// <summary>
/// Gets a factory function for <see cref="IList"/> instances which
/// will hold JSON array values.
/// </summary>
protected Func<IList> ArrayFactory { get; }
/// <summary>
/// Gets a factory function for <see cref="IDictionary"/> instances
/// which will hold JSON object values.
/// </summary>
protected Func<IDictionary> ObjectFactory { get; }
/// <summary>
/// Deserializes a JSON value from a <see cref="Stream"/> with a
/// given quota.
/// </summary>
/// <param name="stream">Stream from which to deserialize a JSON
/// value.</param>
/// <param name="quotas">Quotas to apply during deserialization to
/// prevent attacks from malicious JSON.</param>
/// <returns>The deserialized JSON value.</returns>
/// <seealso cref="JsonReaderWriterFactory.CreateJsonReader(Stream, XmlDictionaryReaderQuotas)"/>
public virtual object Deserialize(Stream stream, XmlDictionaryReaderQuotas quotas)
{
using (XmlReader xmlReader =
JsonReaderWriterFactory.CreateJsonReader(stream, quotas))
{
return this.Deserialize(xmlReader);
}
}
/// <summary>
/// Deserializes a JSON value from given bytes with a given quota.
/// </summary>
/// <param name="bytes">Bytes from which to deserialize a JSON
/// value.</param>
/// <param name="quotas">Quotas to apply during deserialization to
/// prevent attacks from malicious JSON.</param>
/// <returns>The deserialized JSON value.</returns>
/// <seealso cref="JsonReaderWriterFactory.CreateJsonReader(byte[], XmlDictionaryReaderQuotas)"/>
public virtual object Deserialize(byte[] bytes, XmlDictionaryReaderQuotas quotas)
{
using (XmlReader xmlReader =
JsonReaderWriterFactory.CreateJsonReader(bytes, quotas))
{
return this.Deserialize(xmlReader);
}
}
/// <summary>
/// Deserializes a JSON value from an <see cref="XmlReader"/> in the
/// format returned by
/// <see cref="JsonReaderWriterFactory.CreateJsonReader(Stream,XmlDictionaryReaderQuotas)"/>.
///
/// The format is (mostly) documented at:
/// https://docs.microsoft.com/en-us/dotnet/framework/wcf/feature-details/mapping-between-json-and-xml
/// </summary>
/// <param name="reader">XmlReader from which to read a JSON value.</param>
/// <returns>A JSON value read from <paramref name="reader"/>.</returns>
/// <exception cref="ArgumentException">If <paramref name="reader"/>
/// does not contain a JSON value.</exception>
public virtual object Deserialize(XmlReader reader)
{
if (!this.ReadNameAndValue(reader, out string name, out object value))
{
throw new ArgumentException("No JSON in source.", nameof(reader));
}
if (name != "root")
{
Trace.WriteLine(
$"Expected root element name \"root\", got \"{name}\".",
nameof(JsonCollectionDeserializer));
}
return value;
}
/// <summary>
/// Reads a JSON name/value pair at the current
/// <see cref="XmlReader.Depth"/>.
/// </summary>
/// <param name="reader">XmlReader from which to read.</param>
/// <param name="name">Name of the JSON value.</param>
/// <param name="value">JSON value.</param>
/// <returns><c>true</c> if a JSON name/value pair was read at the
/// current Depth (i.e. without an unpaired EndElement), <c>false</c>
/// otherwise. <paramref name="reader"/> will have
/// <see cref="XmlReader.NodeType"/> <see cref="XmlNodeType.EndElement"/>
/// (or <see cref="XmlReader.EOF"/>).</returns>
protected virtual bool ReadNameAndValue(XmlReader reader, out string name, out object value)
{
while (this.ReadNameAndType(reader, out name, out string jsonType))
{
switch (jsonType)
{
case "object":
IDictionary objectValue = this.ObjectFactory();
while (this.ReadNameAndValue(reader, out string key, out object item))
{
objectValue.Add(key, item);
}
value = objectValue;
return true;
case "array":
IList arrayValue = this.ArrayFactory();
while (this.ReadNameAndValue(reader, out string key, out object item))
{
if (key != "item")
{
Trace.WriteLine(
$"Expected array child name \"item\", got \"{key}\".",
nameof(JsonCollectionDeserializer));
}
arrayValue.Add(item);
}
value = arrayValue;
return true;
case "number":
string numberStr = this.ReadContentForType(reader, jsonType);
if (!string.IsNullOrEmpty(numberStr))
{
try
{
value = double.Parse(
numberStr,
NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent | NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite,
NumberFormatInfo.InvariantInfo);
return true;
}
catch (FormatException ex)
{
Trace.WriteLine(
$"Error parsing type=\"{jsonType}\": {ex.Message}",
nameof(JsonCollectionDeserializer));
}
catch (OverflowException ex)
{
Trace.WriteLine(
$"Error parsing type=\"{jsonType}\": {ex.Message}",
nameof(JsonCollectionDeserializer));
}
}
else
{
Trace.WriteLine(
$"Missing value for type=\"{jsonType}\". Ignored.",
nameof(JsonCollectionDeserializer));
}
break;
case "boolean":
string boolStr = this.ReadContentForType(reader, jsonType);
if (boolStr == "true")
{
value = true;
return true;
}
else if (boolStr == "false")
{
value = false;
return true;
}
else
{
Trace.WriteLine(
$"Unrecognized value for type=\"{jsonType}\": \"{boolStr}\"",
nameof(JsonCollectionDeserializer));
}
break;
case "string":
value = this.ReadContentForType(reader, jsonType);
return true;
case "null":
reader.Read();
while (reader.NodeType != XmlNodeType.EndElement && !reader.EOF)
{
if (reader.NodeType == XmlNodeType.Element)
{
Trace.WriteLine(
$"Ignoring {reader.NodeType} {reader.Name} and descendants inside type=\"{jsonType}\".",
nameof(JsonCollectionDeserializer));
}
else if (reader.NodeType != XmlNodeType.Comment)
{
Trace.WriteLine(
$"Ignoring {reader.NodeType} {reader.Name} {reader.Value} inside type=\"{jsonType}\".",
nameof(JsonCollectionDeserializer));
}
reader.Skip();
}
value = null;
return true;
default:
Trace.WriteLine(
$"Unrecognized type {jsonType}.",
nameof(JsonCollectionDeserializer));
break;
}
}
value = null;
return false;
}
/// <summary>
/// Reads to the first JSON <see cref="XmlNodeType.Element"/> at the
/// current <see cref="XmlReader.Depth"/>.
/// </summary>
/// <param name="reader">XmlReader from which to read.</param>
/// <param name="name">Name of the JSON value.</param>
/// <param name="jsonType">JSON value type.</param>
/// <returns><c>true</c> if a JSON Element was read at the current
/// Depth (i.e. without an unpaired EndElement), <c>false</c>
/// otherwise. <paramref name="reader"/> will have
/// <see cref="XmlReader.NodeType"/> <see cref="XmlNodeType.Element"/>
/// when <c>true</c> and <see cref="XmlNodeType.EndElement"/>
/// (or <see cref="XmlReader.EOF"/>) when <c>false</c>.</returns>
private bool ReadNameAndType(XmlReader reader, out string name, out string jsonType)
{
while (reader.Read())
{
switch (reader.NodeType)
{
case XmlNodeType.Element:
string tempName = null;
string namespaceUri = reader.NamespaceURI;
if (string.IsNullOrEmpty(namespaceUri))
{
tempName = reader.LocalName;
}
// Undocumented handling of invalid element names
else if (namespaceUri == "item"
&& reader.LocalName == "item")
{
tempName = reader.GetAttribute("item");
}
if (tempName != null)
{
name = tempName;
jsonType = reader.GetAttribute("type") ?? "string";
return true;
}
Trace.WriteLine(
$"Ignoring {reader.NodeType} {reader.Name} and descendants.",
nameof(JsonCollectionDeserializer));
int depth = reader.Depth;
while (reader.Read() && reader.Depth > depth)
{
// Skip descendant nodes.
}
break;
case XmlNodeType.EndElement:
goto DoneReading;
case XmlNodeType.Comment:
// Ignore silently
break;
default:
Trace.WriteLine(
$"Ignoring {reader.NodeType} {reader.Name} {reader.Value}",
nameof(JsonCollectionDeserializer));
break;
}
}
DoneReading:
name = null;
jsonType = null;
return false;
}
/// <summary>
/// Reads the text content of the current element, excluding descendants.
///
/// Like <see cref="XmlReader.ReadContentAsString"/> except it expects
/// to be called on an <see cref="XmlNodeType.Element"/> and doesn't
/// stop reading at the first element.
/// </summary>
/// <param name="reader">XmlReader from which to read content.
/// Must have <see cref="XmlReader.NodeType"/>
/// <see cref="XmlNodeType.Element"/>.</param>
/// <param name="jsonType">Element type (for logging only).</param>
/// <returns>String content of child nodes of the current</returns>
private string ReadContentForType(XmlReader reader, string jsonType)
{
string firstContent = string.Empty;
StringBuilder stringBuilder = null;
reader.Read();
while (!reader.EOF)
{
switch (reader.NodeType)
{
case XmlNodeType.Element:
Trace.WriteLine(
$"Ignoring {reader.NodeType} {reader.Name} and descendants inside Element with type=\"{jsonType}\".",
nameof(JsonCollectionDeserializer));
reader.Skip();
break;
case XmlNodeType.EndElement:
goto DoneReading;
default:
if (firstContent.Length == 0)
{
firstContent = reader.ReadContentAsString();
}
else
{
if (stringBuilder == null)
{
stringBuilder = new StringBuilder(firstContent);
}
stringBuilder.Append(reader.ReadContentAsString());
}
break;
}
}
DoneReading:
return stringBuilder?.ToString() ?? firstContent;
}
}
}
// <copyright file="JsonCollectionDeserializerTest.cs" company="Kevin Locke">
// Copyright 2018 Kevin Locke
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
// </copyright>
namespace KevinLocke.Json.UnitTests
{
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Text;
using System.Web;
using System.Xml;
using Xunit;
public static class JsonCollectionDeserializerTest
{
[Fact]
public static void ThrowsWhenStreamIsEmpty()
{
// Match behavior of DataContractJsonSerializer,
// where .ReadObject(new MemoryStream()) throws.
// Also disambiguates with null when parsing "null".
Assert.Throws<ArgumentException>(
() => Deserialize(string.Empty));
}
[Theory]
[InlineData("null", null)]
[InlineData("false", false)]
[InlineData("true", true)]
[InlineData("0", 0.0)]
[InlineData("1", 1.0)]
[InlineData("1e10", 1.0e10)]
[InlineData("\"\"", "")]
[InlineData("\" \"", " ")]
[InlineData("\" \\r\\n \"", " \r\n ")]
[InlineData("\"<\"", "<")]
[InlineData("\">\"", ">")]
[InlineData("\"'\"", "'")]
[InlineData("\"\\\"\"", "\"")]
[InlineData("\"&\"", "&")]
[InlineData("\"&lt;\"", "&lt;")]
[InlineData("\"<a/>\"", "<a/>")]
public static void DeserializesPrimitiveType(string json, object value)
{
object deserialized = Deserialize(json);
Assert.Equal(value, deserialized);
}
[Fact]
public static void DeserializesEmptyArray()
{
IList deserialized = (IList)Deserialize("[]");
Assert.Empty(deserialized);
}
[Fact]
public static void DeserializesNonNestedArray()
{
IList deserialized = (IList)Deserialize("[null, true, 0, \"hi\", [], {}]");
Assert.Equal(
new object[]
{
null,
true,
0.0,
"hi",
Array.Empty<object>(),
new OrderedDictionary(),
},
deserialized);
}
[Fact]
public static void DeserializesNestedArray()
{
IList deserialized = (IList)Deserialize("[[[]], [null, true, 0, \"hi\", [], {}]]");
Assert.Equal(
new object[]
{
new object[] { Array.Empty<object>() },
new object[]
{
null,
true,
0.0,
"hi",
Array.Empty<object>(),
new OrderedDictionary(),
},
},
deserialized);
}
[Fact]
public static void DeserializesEmptyObject()
{
IDictionary deserialized = (IDictionary)Deserialize("{}");
Assert.Empty(deserialized);
}
[Fact]
public static void DeserializesNonNestedObject()
{
IDictionary deserialized = (IDictionary)Deserialize("{\"n\": null, \"b\": true, \"i\": 0, \"s\": \"hi\", \"a\": [], \"e\": {}}");
Assert.Equal(
new OrderedDictionary
{
{ "n", null },
{ "b", true },
{ "i", 0.0 },
{ "s", "hi" },
{ "a", Array.Empty<object>() },
{ "e", new OrderedDictionary() },
},
deserialized);
}
[Fact]
public static void DeserializesNestedObject()
{
IDictionary deserialized = (IDictionary)Deserialize("{\"e\": {}, \"o\": {\"n\": null, \"b\": true, \"i\": 0, \"s\": \"hi\", \"a\": [], \"e\": {}}}");
Assert.Equal(
new OrderedDictionary
{
{
"e",
new OrderedDictionary()
},
{
"o",
new OrderedDictionary
{
{ "n", null },
{ "b", true },
{ "i", 0.0 },
{ "s", "hi" },
{ "a", Array.Empty<object>() },
{ "e", new OrderedDictionary() },
}
},
},
deserialized);
}
[Fact]
public static void SupportsCustomArrayType()
{
JsonCollectionDeserializer deserializer =
new JsonCollectionDeserializer(
() => new List<object>(),
null);
List<object> deserialized =
(List<object>)deserializer.Deserialize(
Encoding.UTF8.GetBytes("[1]"),
XmlDictionaryReaderQuotas.Max);
Assert.Equal(
new List<object>
{
1.0,
},
deserialized);
}
[Fact]
public static void SupportsCustomObjectType()
{
JsonCollectionDeserializer deserializer =
new JsonCollectionDeserializer(
null,
() => new Hashtable());
Hashtable deserialized = (Hashtable)deserializer.Deserialize(
Encoding.UTF8.GetBytes("{\"i\": 1}"),
XmlDictionaryReaderQuotas.Max);
Assert.Equal(
new Hashtable
{
{ "i", 1.0 },
},
deserialized);
}
[Fact]
public static void ThrowsForDuplicateKeyByDefault()
{
Assert.Throws<ArgumentException>(
() => Deserialize("{\"a\": true, \"a\": false}"));
}
[Fact]
public static void CanSupportDuplicateKey()
{
JsonCollectionDeserializer deserializer =
new JsonCollectionDeserializer(
null,
() => new OverwritingHashtable());
OverwritingHashtable deserialized =
(OverwritingHashtable)deserializer.Deserialize(
Encoding.UTF8.GetBytes("{\"i\": 1, \"i\": 0}"),
XmlDictionaryReaderQuotas.Max);
Assert.Equal(
new OverwritingHashtable
{
{ "i", 1.0 },
{ "i", 0.0 },
},
deserialized);
}
[Fact]
public static void ReadsFirstOfMultipleRoots()
{
IDictionary deserialized = (IDictionary)Deserialize("{}{}");
Assert.Empty(deserialized);
}
[Theory]
[InlineData("")]
[InlineData("<")]
[InlineData("\\")]
[InlineData("\"")]
[InlineData("&")]
[InlineData(";")]
[InlineData("123")]
[InlineData("a b")]
[InlineData(" a ")]
[InlineData(" a\n ")]
public static void DeserializesPropName(string propName)
{
string propNameJson = HttpUtility.JavaScriptStringEncode(propName, true);
IDictionary deserialized = (IDictionary)Deserialize('{' + propNameJson + ": true}");
Assert.Equal(
new OrderedDictionary
{
{ propName, true },
},
deserialized);
}
private static object Deserialize(string json)
{
return new JsonCollectionDeserializer()
.Deserialize(
Encoding.UTF8.GetBytes(json),
XmlDictionaryReaderQuotas.Max);
}
private class OverwritingHashtable : Hashtable
{
public override void Add(object key, object value)
{
this[key] = value;
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment