Polish ProblemDetails

* Add ability to set extended members on ProblemDetails
* Skip empty valued properties when serializing ProblemDetails

Fixes #8296
Fixes #8317
This commit is contained in:
Pranav K 2018-08-24 11:12:28 -07:00
parent 667ad4daff
commit d09c3c9e28
No known key found for this signature in database
GPG Key ID: 1963DA6D96C3057A
23 changed files with 1175 additions and 16 deletions

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace Microsoft.AspNetCore.Mvc
{
@ -17,28 +18,47 @@ namespace Microsoft.AspNetCore.Mvc
/// (e.g., using HTML [W3C.REC-html5-20141028]). When this member is not present, its value is assumed to be
/// "about:blank".
/// </summary>
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string Type { get; set; }
/// <summary>
/// A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence
/// A short, human-readable summary of the problem type.It SHOULD NOT change from occurrence to occurrence
/// of the problem, except for purposes of localization(e.g., using proactive content negotiation;
/// see[RFC7231], Section 3.4).
/// </summary>
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string Title { get; set; }
/// <summary>
/// The HTTP status code([RFC7231], Section 6) generated by the origin server for this occurrence of the problem.
/// </summary>
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public int? Status { get; set; }
/// <summary>
/// A human-readable explanation specific to this occurrence of the problem.
/// </summary>
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string Detail { get; set; }
/// <summary>
/// A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced.
/// A URI reference that identifies the specific occurrence of the problem.It may or may not yield further information if dereferenced.
/// </summary>
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string Instance { get; set; }
/// <summary>
/// Gets the <see cref="IDictionary{TKey, TValue}"/> for extension members.
/// <para>
/// Problem type definitions MAY extend the problem details object with additional members. Extension members appear in the same namespace as
/// other members of a problem type.
/// </para>
/// </summary>
/// <remarks>
/// The round-tripping behavior for <see cref="Extensions"/> is determined by the implementation of the Input \ Output formatters.
/// In particular, complex types or collection types may not round-trip to the original type when using the built-in JSON or XML formatters.
/// </remarks>
[JsonExtensionData]
public IDictionary<string, object> Extensions { get; } = new Dictionary<string, object>(StringComparer.Ordinal);
}
}

View File

@ -0,0 +1,187 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Globalization;
using System.Xml;
using System.Xml.Schema;
using System.Xml.Serialization;
namespace Microsoft.AspNetCore.Mvc.Formatters.Xml
{
/// <summary>
/// Wrapper class for <see cref="Mvc.ProblemDetails"/> to enable it to be serialized by the xml formatters.
/// </summary>
[XmlRoot(nameof(ProblemDetails))]
public class ProblemDetailsWrapper : IXmlSerializable, IUnwrappable
{
/// <summary>
/// Key used to represent dictionary elements with empty keys
/// </summary>
protected static readonly string EmptyKey = SerializableErrorWrapper.EmptyKey;
/// <summary>
/// Initializes a new instance of <see cref="ProblemDetailsWrapper"/>.
/// </summary>
public ProblemDetailsWrapper()
: this(new ProblemDetails())
{
}
/// <summary>
/// Initializes a new instance of <see cref="ProblemDetailsWrapper"/>.
/// </summary>
public ProblemDetailsWrapper(ProblemDetails problemDetails)
{
ProblemDetails = problemDetails;
}
internal ProblemDetails ProblemDetails { get; }
/// <inheritdoc />
public XmlSchema GetSchema() => null;
/// <inheritdoc />
public virtual void ReadXml(XmlReader reader)
{
if (reader == null)
{
throw new ArgumentNullException(nameof(reader));
}
if (reader.IsEmptyElement)
{
reader.Read();
return;
}
reader.ReadStartElement();
while (reader.NodeType != XmlNodeType.EndElement)
{
var key = XmlConvert.DecodeName(reader.LocalName);
ReadValue(reader, key);
reader.MoveToContent();
}
reader.ReadEndElement();
}
/// <summary>
/// Reads the value for the specified <paramref name="name"/> from the <paramref name="reader"/>.
/// </summary>
/// <param name="reader">The <see cref="XmlReader"/>.</param>
/// <param name="name">The name of the node.</param>
protected virtual void ReadValue(XmlReader reader, string name)
{
if (reader == null)
{
throw new ArgumentNullException(nameof(reader));
}
var value = reader.ReadInnerXml();
switch (name)
{
case nameof(ProblemDetails.Detail):
ProblemDetails.Detail = value;
break;
case nameof(ProblemDetails.Instance):
ProblemDetails.Instance = value;
break;
case nameof(ProblemDetails.Status):
ProblemDetails.Status = string.IsNullOrEmpty(value) ?
(int?)null :
int.Parse(value, CultureInfo.InvariantCulture);
break;
case nameof(ProblemDetails.Title):
ProblemDetails.Title = value;
break;
case nameof(ProblemDetails.Type):
ProblemDetails.Type = value;
break;
default:
if (string.Equals(name, EmptyKey, StringComparison.Ordinal))
{
name = string.Empty;
}
ProblemDetails.Extensions.Add(name, value);
break;
}
}
/// <inheritdoc />
public virtual void WriteXml(XmlWriter writer)
{
if (!string.IsNullOrEmpty(ProblemDetails.Detail))
{
writer.WriteElementString(
XmlConvert.EncodeLocalName(nameof(ProblemDetails.Detail)),
ProblemDetails.Detail);
}
if (!string.IsNullOrEmpty(ProblemDetails.Instance))
{
writer.WriteElementString(
XmlConvert.EncodeLocalName(nameof(ProblemDetails.Instance)),
ProblemDetails.Instance);
}
if (ProblemDetails.Status.HasValue)
{
writer.WriteStartElement(XmlConvert.EncodeLocalName(nameof(ProblemDetails.Status)));
writer.WriteValue(ProblemDetails.Status.Value);
writer.WriteEndElement();
}
if (!string.IsNullOrEmpty(ProblemDetails.Title))
{
writer.WriteElementString(
XmlConvert.EncodeLocalName(nameof(ProblemDetails.Title)),
ProblemDetails.Title);
}
if (!string.IsNullOrEmpty(ProblemDetails.Type))
{
writer.WriteElementString(
XmlConvert.EncodeLocalName(nameof(ProblemDetails.Type)),
ProblemDetails.Type);
}
foreach (var keyValuePair in ProblemDetails.Extensions)
{
var key = keyValuePair.Key;
var value = keyValuePair.Value;
if (string.IsNullOrEmpty(key))
{
key = EmptyKey;
}
writer.WriteStartElement(XmlConvert.EncodeLocalName(key));
if (value != null)
{
writer.WriteValue(value);
}
writer.WriteEndElement();
}
}
object IUnwrappable.Unwrap(Type declaredType)
{
if (declaredType == null)
{
throw new ArgumentNullException(nameof(declaredType));
}
return ProblemDetails;
}
}
}

View File

@ -0,0 +1,6 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Formatters.Xml.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]

View File

@ -16,7 +16,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml
{
// Element name used when ModelStateEntry's Key is empty. Dash in element name should avoid collisions with
// other ModelState entries because the character is not legal in an expression name.
private static readonly string EmptyKey = "MVC-Empty";
internal static readonly string EmptyKey = "MVC-Empty";
// Note: XmlSerializer requires to have default constructor
public SerializableErrorWrapper()

View File

@ -0,0 +1,126 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Xml;
using System.Xml.Serialization;
namespace Microsoft.AspNetCore.Mvc.Formatters.Xml
{
/// <summary>
/// Wrapper class for <see cref="ValidationProblemDetails"/> to enable it to be serialized by the xml formatters.
/// </summary>
[XmlRoot(nameof(ValidationProblemDetails))]
public class ValidationProblemDetailsWrapper : ProblemDetailsWrapper, IUnwrappable
{
private static readonly string ErrorKey = "MVC-Errors";
/// <summary>
/// Initializes a new instance of <see cref="ValidationProblemDetailsWrapper"/>.
/// </summary>
public ValidationProblemDetailsWrapper()
: this(new ValidationProblemDetails())
{
}
/// <summary>
/// Initializes a new instance of <see cref="ValidationProblemDetailsWrapper"/> for the specified
/// <paramref name="problemDetails"/>.
/// </summary>
/// <param name="problemDetails">The <see cref="ProblemDetails"/>.</param>
public ValidationProblemDetailsWrapper(ValidationProblemDetails problemDetails)
: base(problemDetails)
{
ProblemDetails = problemDetails;
}
internal new ValidationProblemDetails ProblemDetails { get; }
/// <inheritdoc />
protected override void ReadValue(XmlReader reader, string name)
{
if (reader == null)
{
throw new ArgumentNullException(nameof(reader));
}
if (string.Equals(name, ErrorKey, StringComparison.Ordinal))
{
reader.Read();
ReadErrorProperty(reader);
}
else
{
base.ReadValue(reader, name);
}
}
private void ReadErrorProperty(XmlReader reader)
{
if (reader.IsEmptyElement)
{
return;
}
while (reader.NodeType != XmlNodeType.EndElement)
{
var key = XmlConvert.DecodeName(reader.LocalName);
var value = reader.ReadInnerXml();
if (string.Equals(EmptyKey, key, StringComparison.Ordinal))
{
key = string.Empty;
}
ProblemDetails.Errors.Add(key, new[] { value });
reader.MoveToContent();
}
}
/// <inheritdoc />
public override void WriteXml(XmlWriter writer)
{
if (writer == null)
{
throw new ArgumentNullException(nameof(writer));
}
base.WriteXml(writer);
if (ProblemDetails.Errors.Count == 0)
{
return;
}
writer.WriteStartElement(XmlConvert.EncodeLocalName(ErrorKey));
foreach (var keyValuePair in ProblemDetails.Errors)
{
var key = keyValuePair.Key;
var value = keyValuePair.Value;
if (string.IsNullOrEmpty(key))
{
key = EmptyKey;
}
writer.WriteStartElement(XmlConvert.EncodeLocalName(key));
if (value != null)
{
writer.WriteValue(value);
}
writer.WriteEndElement();
}
writer.WriteEndElement();
}
object IUnwrappable.Unwrap(Type declaredType)
{
if (declaredType == null)
{
throw new ArgumentNullException(nameof(declaredType));
}
return ProblemDetails;
}
}
}

View File

@ -44,5 +44,24 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml
return null;
}
internal static IList<IWrapperProviderFactory> GetDefaultProviderFactories()
{
var wrapperProviderFactories = new List<IWrapperProviderFactory>();
wrapperProviderFactories.Add(new SerializableErrorWrapperProviderFactory());
wrapperProviderFactories.Add(new WrapperProviderFactory(
typeof(ProblemDetails),
typeof(ProblemDetailsWrapper),
value => new ProblemDetailsWrapper((ProblemDetails)value)));
wrapperProviderFactories.Add(new WrapperProviderFactory(
typeof(ValidationProblemDetails),
typeof(ValidationProblemDetailsWrapper),
value => new ValidationProblemDetailsWrapper((ValidationProblemDetails)value)));
return wrapperProviderFactories;
}
}
}

View File

@ -0,0 +1,50 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
namespace Microsoft.AspNetCore.Mvc.Formatters.Xml
{
internal class WrapperProviderFactory : IWrapperProviderFactory
{
public WrapperProviderFactory(Type declaredType, Type wrappingType, Func<object, object> wrapper)
{
DeclaredType = declaredType;
WrappingType = wrappingType;
Wrapper = wrapper;
}
public Type DeclaredType { get; }
public Type WrappingType { get; }
public Func<object, object> Wrapper { get; }
public IWrapperProvider GetProvider(WrapperProviderContext context)
{
if (context.DeclaredType == DeclaredType)
{
return new WrapperProvider(this);
}
return null;
}
private class WrapperProvider : IWrapperProvider
{
private readonly WrapperProviderFactory _wrapperFactory;
public WrapperProvider(WrapperProviderFactory wrapperFactory)
{
_wrapperFactory = wrapperFactory;
}
public Type WrappingType => _wrapperFactory.WrappingType;
public object Wrap(object original)
{
return _wrapperFactory.Wrapper(original);
}
}
}
}

View File

@ -46,8 +46,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
_serializerSettings = new DataContractSerializerSettings();
WrapperProviderFactories = new List<IWrapperProviderFactory>();
WrapperProviderFactories.Add(new SerializableErrorWrapperProviderFactory());
WrapperProviderFactories = WrapperProviderFactoriesExtensions.GetDefaultProviderFactories();
}
/// <summary>

View File

@ -76,9 +76,8 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
_serializerSettings = new DataContractSerializerSettings();
WrapperProviderFactories = new List<IWrapperProviderFactory>();
WrapperProviderFactories = WrapperProviderFactoriesExtensions.GetDefaultProviderFactories();
WrapperProviderFactories.Add(new EnumerableWrapperProviderFactory(WrapperProviderFactories));
WrapperProviderFactories.Add(new SerializableErrorWrapperProviderFactory());
_logger = loggerFactory?.CreateLogger(GetType());
}

View File

@ -43,8 +43,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
SupportedMediaTypes.Add(MediaTypeHeaderValues.TextXml);
SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationAnyXmlSyntax);
WrapperProviderFactories = new List<IWrapperProviderFactory>();
WrapperProviderFactories.Add(new SerializableErrorWrapperProviderFactory());
WrapperProviderFactories = WrapperProviderFactoriesExtensions.GetDefaultProviderFactories();
}
/// <summary>

View File

@ -73,9 +73,8 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
WriterSettings = writerSettings;
WrapperProviderFactories = new List<IWrapperProviderFactory>();
WrapperProviderFactories = WrapperProviderFactoriesExtensions.GetDefaultProviderFactories();
WrapperProviderFactories.Add(new EnumerableWrapperProviderFactory(WrapperProviderFactories));
WrapperProviderFactories.Add(new SerializableErrorWrapperProviderFactory());
_logger = loggerFactory?.CreateLogger(GetType());
}

View File

@ -0,0 +1,100 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.IO;
using System.Linq;
using System.Runtime.Serialization;
using System.Text;
using System.Xml;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.Formatters.Xml
{
public class ProblemDetailsWrapperTest
{
[Fact]
public void ReadXml_ReadsProblemDetailsXml()
{
// Arrange
var xml = "<?xml version=\"1.0\" encoding=\"utf-8\"?>" +
"<ProblemDetails>" +
"<Title>Some title</Title>" +
"<Status>403</Status>" +
"<Instance>Some instance</Instance>" +
"<key1>Test Value 1</key1>" +
"<_x005B_key2_x005D_>Test Value 2</_x005B_key2_x005D_>" +
"<MVC-Empty>Test Value 3</MVC-Empty>" +
"</ProblemDetails>";
var serializer = new DataContractSerializer(typeof(ProblemDetailsWrapper));
// Act
var value = serializer.ReadObject(
new MemoryStream(Encoding.UTF8.GetBytes(xml)));
// Assert
var problemDetails = Assert.IsType<ProblemDetailsWrapper>(value).ProblemDetails;
Assert.Equal("Some title", problemDetails.Title);
Assert.Equal("Some instance", problemDetails.Instance);
Assert.Equal(403, problemDetails.Status);
Assert.Collection(
problemDetails.Extensions.OrderBy(kvp => kvp.Key),
kvp =>
{
Assert.Empty(kvp.Key);
Assert.Equal("Test Value 3", kvp.Value);
},
kvp =>
{
Assert.Equal("[key2]", kvp.Key);
Assert.Equal("Test Value 2", kvp.Value);
},
kvp =>
{
Assert.Equal("key1", kvp.Key);
Assert.Equal("Test Value 1", kvp.Value);
});
}
[Fact]
public void WriteXml_WritesValidXml()
{
// Arrange
var problemDetails = new ProblemDetails
{
Title = "Some title",
Detail = "Some detail",
Extensions =
{
["key1"] = "Test Value 1",
["[Key2]"] = "Test Value 2",
[""] = "Test Value 3",
},
};
var wrapper = new ProblemDetailsWrapper(problemDetails);
var outputStream = new MemoryStream();
var expectedContent = "<?xml version=\"1.0\" encoding=\"utf-8\"?>" +
"<ProblemDetails>" +
"<Detail>Some detail</Detail>" +
"<Title>Some title</Title>" +
"<key1>Test Value 1</key1>" +
"<_x005B_Key2_x005D_>Test Value 2</_x005B_Key2_x005D_>" +
"<MVC-Empty>Test Value 3</MVC-Empty>" +
"</ProblemDetails>";
// Act
using (var xmlWriter = XmlWriter.Create(outputStream))
{
var dataContractSerializer = new DataContractSerializer(wrapper.GetType());
dataContractSerializer.WriteObject(xmlWriter, wrapper);
}
outputStream.Position = 0;
var res = new StreamReader(outputStream, Encoding.UTF8).ReadToEnd();
// Assert
Assert.Equal(expectedContent, res);
}
}
}

View File

@ -0,0 +1,226 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.IO;
using System.Linq;
using System.Runtime.Serialization;
using System.Text;
using System.Xml;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.Formatters.Xml
{
public class ValidationProblemDetailsWrapperTest
{
[Fact]
public void ReadXml_ReadsValidationProblemDetailsXml()
{
// Arrange
var xml = "<?xml version=\"1.0\" encoding=\"utf-8\"?>" +
"<ValidationProblemDetails>" +
"<Title>Some title</Title>" +
"<Status>400</Status>" +
"<Instance>Some instance</Instance>" +
"<key1>Test Value 1</key1>" +
"<_x005B_key2_x005D_>Test Value 2</_x005B_key2_x005D_>" +
"<MVC-Errors>" +
"<error1>Test error 1 Test error 2</error1>" +
"<_x005B_error2_x005D_>Test error 3</_x005B_error2_x005D_>" +
"<MVC-Empty>Test error 4</MVC-Empty>" +
"</MVC-Errors>" +
"</ValidationProblemDetails>";
var serializer = new DataContractSerializer(typeof(ValidationProblemDetailsWrapper));
// Act
var value = serializer.ReadObject(
new MemoryStream(Encoding.UTF8.GetBytes(xml)));
// Assert
var problemDetails = Assert.IsType<ValidationProblemDetailsWrapper>(value).ProblemDetails;
Assert.Equal("Some title", problemDetails.Title);
Assert.Equal("Some instance", problemDetails.Instance);
Assert.Equal(400, problemDetails.Status);
Assert.Collection(
problemDetails.Extensions.OrderBy(kvp => kvp.Key),
kvp =>
{
Assert.Equal("[key2]", kvp.Key);
Assert.Equal("Test Value 2", kvp.Value);
},
kvp =>
{
Assert.Equal("key1", kvp.Key);
Assert.Equal("Test Value 1", kvp.Value);
});
Assert.Collection(
problemDetails.Errors.OrderBy(kvp => kvp.Key),
kvp =>
{
Assert.Empty(kvp.Key);
Assert.Equal(new[] { "Test error 4" }, kvp.Value);
},
kvp =>
{
Assert.Equal("[error2]", kvp.Key);
Assert.Equal(new[] { "Test error 3" }, kvp.Value);
},
kvp =>
{
Assert.Equal("error1", kvp.Key);
Assert.Equal(new[] { "Test error 1 Test error 2" }, kvp.Value);
});
}
[Fact]
public void ReadXml_ReadsValidationProblemDetailsXml_WithNoErrors()
{
// Arrange
var xml = "<?xml version=\"1.0\" encoding=\"utf-8\"?>" +
"<ValidationProblemDetails>" +
"<Title>Some title</Title>" +
"<Status>400</Status>" +
"<Instance>Some instance</Instance>" +
"<key1>Test Value 1</key1>" +
"<_x005B_key2_x005D_>Test Value 2</_x005B_key2_x005D_>" +
"</ValidationProblemDetails>";
var serializer = new DataContractSerializer(typeof(ValidationProblemDetailsWrapper));
// Act
var value = serializer.ReadObject(
new MemoryStream(Encoding.UTF8.GetBytes(xml)));
// Assert
var problemDetails = Assert.IsType<ValidationProblemDetailsWrapper>(value).ProblemDetails;
Assert.Equal("Some title", problemDetails.Title);
Assert.Equal("Some instance", problemDetails.Instance);
Assert.Equal(400, problemDetails.Status);
Assert.Collection(
problemDetails.Extensions,
kvp =>
{
Assert.Equal("key1", kvp.Key);
Assert.Equal("Test Value 1", kvp.Value);
},
kvp =>
{
Assert.Equal("[key2]", kvp.Key);
Assert.Equal("Test Value 2", kvp.Value);
});
Assert.Empty(problemDetails.Errors);
}
[Fact]
public void ReadXml_ReadsValidationProblemDetailsXml_WithEmptyErrorsElement()
{
// Arrange
var xml = "<?xml version=\"1.0\" encoding=\"utf-8\"?>" +
"<ValidationProblemDetails>" +
"<Title>Some title</Title>" +
"<Status>400</Status>" +
"<MVC-Errors />" +
"</ValidationProblemDetails>";
var serializer = new DataContractSerializer(typeof(ValidationProblemDetailsWrapper));
// Act
var value = serializer.ReadObject(
new MemoryStream(Encoding.UTF8.GetBytes(xml)));
// Assert
var problemDetails = Assert.IsType<ValidationProblemDetailsWrapper>(value).ProblemDetails;
Assert.Equal("Some title", problemDetails.Title);
Assert.Equal(400, problemDetails.Status);
Assert.Empty(problemDetails.Errors);
}
[Fact]
public void WriteXml_WritesValidXml()
{
// Arrange
var problemDetails = new ValidationProblemDetails
{
Title = "Some title",
Detail = "Some detail",
Extensions =
{
["key1"] = "Test Value 1",
["[Key2]"] = "Test Value 2"
},
Errors =
{
{ "error1", new[] {"Test error 1", "Test error 2" } },
{ "[error2]", new[] {"Test error 3" } },
{ "", new[] { "Test error 4" } },
}
};
var wrapper = new ValidationProblemDetailsWrapper(problemDetails);
var outputStream = new MemoryStream();
var expectedContent = "<?xml version=\"1.0\" encoding=\"utf-8\"?>" +
"<ValidationProblemDetails>" +
"<Detail>Some detail</Detail>" +
"<Title>Some title</Title>" +
"<key1>Test Value 1</key1>" +
"<_x005B_Key2_x005D_>Test Value 2</_x005B_Key2_x005D_>" +
"<MVC-Errors>" +
"<error1>Test error 1 Test error 2</error1>" +
"<_x005B_error2_x005D_>Test error 3</_x005B_error2_x005D_>" +
"<MVC-Empty>Test error 4</MVC-Empty>" +
"</MVC-Errors>" +
"</ValidationProblemDetails>";
// Act
using (var xmlWriter = XmlWriter.Create(outputStream))
{
var dataContractSerializer = new DataContractSerializer(wrapper.GetType());
dataContractSerializer.WriteObject(xmlWriter, wrapper);
}
outputStream.Position = 0;
var res = new StreamReader(outputStream, Encoding.UTF8).ReadToEnd();
// Assert
Assert.Equal(expectedContent, res);
}
[Fact]
public void WriteXml_WithNoValidationErrors()
{
// Arrange
var problemDetails = new ValidationProblemDetails
{
Title = "Some title",
Detail = "Some detail",
Extensions =
{
["key1"] = "Test Value 1",
["[Key2]"] = "Test Value 2"
},
};
var wrapper = new ValidationProblemDetailsWrapper(problemDetails);
var outputStream = new MemoryStream();
var expectedContent = "<?xml version=\"1.0\" encoding=\"utf-8\"?>" +
"<ValidationProblemDetails>" +
"<Detail>Some detail</Detail>" +
"<Title>Some title</Title>" +
"<key1>Test Value 1</key1>" +
"<_x005B_Key2_x005D_>Test Value 2</_x005B_Key2_x005D_>" +
"</ValidationProblemDetails>";
// Act
using (var xmlWriter = XmlWriter.Create(outputStream))
{
var dataContractSerializer = new DataContractSerializer(wrapper.GetType());
dataContractSerializer.WriteObject(xmlWriter, wrapper);
}
outputStream.Position = 0;
var res = new StreamReader(outputStream, Encoding.UTF8).ReadToEnd();
// Assert
Assert.Equal(expectedContent, res);
}
}
}

View File

@ -0,0 +1,34 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Xunit;
namespace Microsoft.AspNetCore.Mvc.Formatters.Xml
{
public class WrapperProviderFactoryExtensionsTest
{
[Fact]
public void GetDefaultProviderFactories_GetsFactoriesUsedByInputAndOutputFormatters()
{
// Act
var factoryProviders = WrapperProviderFactoriesExtensions.GetDefaultProviderFactories();
// Assert
Assert.Collection(
factoryProviders,
factory => Assert.IsType<SerializableErrorWrapperProviderFactory>(factory),
factory =>
{
var wrapperProviderFactory = Assert.IsType<WrapperProviderFactory>(factory);
Assert.Equal(typeof(ProblemDetails), wrapperProviderFactory.DeclaredType);
Assert.Equal(typeof(ProblemDetailsWrapper), wrapperProviderFactory.WrappingType);
},
factory =>
{
var wrapperProviderFactory = Assert.IsType<WrapperProviderFactory>(factory);
Assert.Equal(typeof(ValidationProblemDetails), wrapperProviderFactory.DeclaredType);
Assert.Equal(typeof(ValidationProblemDetailsWrapper), wrapperProviderFactory.WrappingType);
});
}
}
}

View File

@ -0,0 +1,63 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Xunit;
namespace Microsoft.AspNetCore.Mvc.Formatters.Xml
{
public class WrapperProviderFactoryTest
{
[Fact]
public void GetProvider_ReturnsNull_IfTypeDoesNotMatch()
{
// Arrange
var provider = new WrapperProviderFactory(
typeof(ProblemDetails),
typeof(ProblemDetailsWrapper),
_ => null);
var context = new WrapperProviderContext(typeof(SerializableError), isSerialization: true);
// Act
var result = provider.GetProvider(context);
// Assert
Assert.Null(result);
}
[Fact]
public void GetProvider_ReturnsNull_IfTypeIsSubtype()
{
// Arrange
var provider = new WrapperProviderFactory(
typeof(ProblemDetails),
typeof(ProblemDetailsWrapper),
_ => null);
var context = new WrapperProviderContext(typeof(ValidationProblemDetails), isSerialization: true);
// Act
var result = provider.GetProvider(context);
// Assert
Assert.Null(result);
}
[Fact]
public void GetProvider_ReturnsValue_IfTypeMatches()
{
// Arrange
var expected = new object();
var providerFactory = new WrapperProviderFactory(
typeof(ProblemDetails),
typeof(ProblemDetailsWrapper),
_ => expected);
var context = new WrapperProviderContext(typeof(ProblemDetails), isSerialization: true);
// Act
var provider = providerFactory.GetProvider(context);
var result = provider.Wrap(new ProblemDetails());
// Assert
Assert.Same(expected, result);
}
}
}

View File

@ -10,6 +10,7 @@ using System.Threading.Tasks;
using BasicWebSite.Models;
using Microsoft.AspNetCore.Hosting;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.FunctionalTests
@ -116,8 +117,8 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
};
var expected = new Dictionary<string, string[]>
{
{"Name", new string[] {"The field Name must be a string with a minimum length of 5 and a maximum length of 30."}},
{"Zip", new string[]{ @"The field Zip must match the regular expression '\d{5}'."}}
{"Name", new[] {"The field Name must be a string with a minimum length of 5 and a maximum length of 30."}},
{"Zip", new[] { @"The field Zip must match the regular expression '\d{5}'."}}
};
var contactString = JsonConvert.SerializeObject(contactModel);
@ -261,5 +262,62 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
var problemDetails = JsonConvert.DeserializeObject<ProblemDetails>(content);
Assert.Equal(404, problemDetails.Status);
}
[Fact]
public async Task SerializingProblemDetails_IgnoresNullValuedProperties()
{
// Arrange
var expected = new[] { "status", "title", "type" };
// Act
var response = await Client.GetAsync("/contact/ActionReturningStatusCodeResult");
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.NotFound);
var content = await response.Content.ReadAsStringAsync();
// Verify that null-valued properties on ProblemDetails are not serialized.
var json = JObject.Parse(content);
Assert.Equal(expected, json.Properties().OrderBy(p => p.Name).Select(p => p.Name));
}
[Fact]
public async Task SerializingProblemDetails_WithAllValuesSpecified()
{
// Arrange
var expected = new[] { "detail", "instance", "status", "title", "tracking-id", "type" };
// Act
var response = await Client.GetAsync("/contact/ActionReturningProblemDetails");
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.NotFound);
var content = await response.Content.ReadAsStringAsync();
var json = JObject.Parse(content);
Assert.Equal(expected, json.Properties().OrderBy(p => p.Name).Select(p => p.Name));
}
[Fact]
public async Task SerializingValidationProblemDetails_WithExtensionData()
{
// Act
var response = await Client.GetAsync("/contact/ActionReturningValidationProblemDetails");
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest);
var content = await response.Content.ReadAsStringAsync();
var validationProblemDetails = JsonConvert.DeserializeObject<ValidationProblemDetails>(content);
Assert.Equal("Error", validationProblemDetails.Title);
Assert.Equal(400, validationProblemDetails.Status);
Assert.Equal("27", validationProblemDetails.Extensions["tracking-id"]);
Assert.Collection(
validationProblemDetails.Errors,
kvp =>
{
Assert.Equal("Error1", kvp.Key);
Assert.Equal(new[] { "Error Message" }, kvp.Value);
});
}
}
}

View File

@ -53,7 +53,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
throw new StatusCodeMismatchException
{
ExpectedStatusCode = HttpStatusCode.OK,
ExpectedStatusCode = expectedStatusCode,
ActualStatusCode = response.StatusCode,
ResponseContent = responseContent,
};
@ -71,7 +71,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
{
get
{
return $"Excepted status code 200. Actual {ActualStatusCode}. Response Content:" + Environment.NewLine + ResponseContent;
return $"Excepted status code {ExpectedStatusCode}. Actual {ActualStatusCode}. Response Content:" + Environment.NewLine + ResponseContent;
}
}
}

View File

@ -208,5 +208,68 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
"</ArrayOfSerializableErrorWrapper>",
result);
}
[Fact]
public async Task ProblemDetails_IsSerialized()
{
// Arrange
var expected = @"<ProblemDetails><Status>404</Status><Title>Not Found</Title><Type>https://tools.ietf.org/html/rfc7231#section-6.5.4</Type></ProblemDetails>";
// Act
var response = await Client.GetAsync("/api/XmlDataContractApi/ActionReturningClientErrorStatusCodeResult");
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.NotFound);
var content = await response.Content.ReadAsStringAsync();
XmlAssert.Equal(expected, content);
}
[Fact]
public async Task ProblemDetails_WithExtensionMembers_IsSerialized()
{
// Arrange
var expected = @"<ProblemDetails><Instance>instance</Instance><Status>404</Status><Title>title</Title>
<Correlation>correlation</Correlation><Accounts>Account1 Account2</Accounts></ProblemDetails>";
// Act
var response = await Client.GetAsync("/api/XmlDataContractApi/ActionReturningProblemDetails");
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.NotFound);
var content = await response.Content.ReadAsStringAsync();
XmlAssert.Equal(expected, content);
}
[Fact]
public async Task ValidationProblemDetails_IsSerialized()
{
// Arrange
var expected = @"<ValidationProblemDetails><Status>400</Status><Title>One or more validation errors occurred.</Title>
<MVC-Errors><State>The State field is required.</State></MVC-Errors></ValidationProblemDetails>";
// Act
var response = await Client.GetAsync("/api/XmlDataContractApi/ActionReturningValidationProblem");
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest);
var content = await response.Content.ReadAsStringAsync();
XmlAssert.Equal(expected, content);
}
[Fact]
public async Task ValidationProblemDetails_WithExtensionMembers_IsSerialized()
{
// Arrange
var expected = @"<ValidationProblemDetails><Detail>some detail</Detail><Status>400</Status><Title>One or more validation errors occurred.</Title>
<Type>some type</Type><CorrelationId>correlation</CorrelationId><MVC-Errors><Error1>ErrorValue</Error1></MVC-Errors></ValidationProblemDetails>";
// Act
var response = await Client.GetAsync("/api/XmlDataContractApi/ActionReturningValidationDetailsWithMetadata");
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest);
var content = await response.Content.ReadAsStringAsync();
XmlAssert.Equal(expected, content);
}
}
}
}

View File

@ -183,5 +183,68 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
"<key4>key2-error</key4></SerializableErrorWrapper></ArrayOfSerializableErrorWrapper>",
result);
}
[Fact]
public async Task ProblemDetails_IsSerialized()
{
// Arrange
var expected = @"<ProblemDetails><Status>404</Status><Title>Not Found</Title><Type>https://tools.ietf.org/html/rfc7231#section-6.5.4</Type></ProblemDetails>";
// Act
var response = await Client.GetAsync("/api/XmlSerializerApi/ActionReturningClientErrorStatusCodeResult");
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.NotFound);
var content = await response.Content.ReadAsStringAsync();
XmlAssert.Equal(expected, content);
}
[Fact]
public async Task ProblemDetails_WithExtensionMembers_IsSerialized()
{
// Arrange
var expected = @"<ProblemDetails><Instance>instance</Instance><Status>404</Status><Title>title</Title>
<Correlation>correlation</Correlation><Accounts>Account1 Account2</Accounts></ProblemDetails>";
// Act
var response = await Client.GetAsync("/api/XmlSerializerApi/ActionReturningProblemDetails");
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.NotFound);
var content = await response.Content.ReadAsStringAsync();
XmlAssert.Equal(expected, content);
}
[Fact]
public async Task ValidationProblemDetails_IsSerialized()
{
// Arrange
var expected = @"<ValidationProblemDetails><Status>400</Status><Title>One or more validation errors occurred.</Title>
<MVC-Errors><State>The State field is required.</State></MVC-Errors></ValidationProblemDetails>";
// Act
var response = await Client.GetAsync("/api/XmlSerializerApi/ActionReturningValidationProblem");
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest);
var content = await response.Content.ReadAsStringAsync();
XmlAssert.Equal(expected, content);
}
[Fact]
public async Task ValidationProblemDetails_WithExtensionMembers_IsSerialized()
{
// Arrange
var expected = @"<ValidationProblemDetails><Detail>some detail</Detail><Status>400</Status><Title>One or more validation errors occurred.</Title>
<Type>some type</Type><CorrelationId>correlation</CorrelationId><MVC-Errors><Error1>ErrorValue</Error1></MVC-Errors></ValidationProblemDetails>";
// Act
var response = await Client.GetAsync("/api/XmlSerializerApi/ActionReturningValidationDetailsWithMetadata");
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest);
var content = await response.Content.ReadAsStringAsync();
XmlAssert.Equal(expected, content);
}
}
}

View File

@ -91,6 +91,41 @@ namespace BasicWebSite
return NotFound();
}
[HttpGet("[action]")]
public ActionResult<int> ActionReturningProblemDetails()
{
return NotFound(new ProblemDetails
{
Title = "Not Found",
Type = "Type",
Detail = "Detail",
Status = 404,
Instance = "Instance",
Extensions =
{
["tracking-id"] = 27,
},
});
}
[HttpGet("[action]")]
public ActionResult<int> ActionReturningValidationProblemDetails()
{
return BadRequest(new ValidationProblemDetails
{
Title = "Error",
Status = 400,
Extensions =
{
["tracking-id"] = "27",
},
Errors =
{
{ "Error1", new[] { "Error Message" } },
},
});
}
private class TestModelBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)

View File

@ -0,0 +1,55 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using Microsoft.AspNetCore.Mvc;
using XmlFormattersWebSite.Models;
namespace XmlFormattersWebSite
{
[ApiController]
[Route("api/[controller]/[action]")]
public abstract class XmlApiControllerBase : ControllerBase
{
[HttpGet]
public ActionResult<Person> ActionReturningClientErrorStatusCodeResult()
=> NotFound();
[HttpGet]
public ActionResult<Person> ActionReturningProblemDetails()
{
return NotFound(new ProblemDetails
{
Instance = "instance",
Title = "title",
Extensions =
{
["Correlation"] = "correlation",
["Accounts"] = new[] { "Account1", "Account2" },
},
});
}
[HttpGet]
public ActionResult<Person> ActionReturningValidationProblem([FromQuery] Address address)
=> throw new NotImplementedException();
[HttpGet]
public ActionResult<Person> ActionReturningValidationDetailsWithMetadata()
{
return new BadRequestObjectResult(new ValidationProblemDetails
{
Detail = "some detail",
Type = "some type",
Extensions =
{
["CorrelationId"] = "correlation",
},
Errors =
{
["Error1"] = new[] { "ErrorValue"},
},
});
}
}
}

View File

@ -0,0 +1,29 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.Formatters;
namespace XmlFormattersWebSite
{
[SetupOutputFormatters]
public class XmlDataContractApiController : XmlApiControllerBase
{
private class SetupOutputFormattersAttribute : ResultFilterAttribute
{
public override void OnResultExecuting(ResultExecutingContext context)
{
if (!(context.Result is ObjectResult objectResult))
{
return;
}
// Both kinds of Xml serializers are configured for this application and use custom content-types to do formatter
// selection. The globally configured formatters rely on custom content-type to perform conneg which does not play
// well the ProblemDetails returning filters that defaults to using application/xml. We'll explicitly select the formatter for this controller.
objectResult.Formatters.Add(new XmlDataContractSerializerOutputFormatter());
}
}
}
}

View File

@ -0,0 +1,29 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.Formatters;
namespace XmlFormattersWebSite
{
[SetupOutputFormatters]
public class XmlSerializerApiController : XmlApiControllerBase
{
private class SetupOutputFormattersAttribute : ResultFilterAttribute
{
public override void OnResultExecuting(ResultExecutingContext context)
{
if (!(context.Result is ObjectResult objectResult))
{
return;
}
// Both kinds of Xml serializers are configured for this application and use custom content-types to do formatter
// selection. The globally configured formatters rely on custom content-type to perform conneg which does not play
// well the ProblemDetails returning filters that defaults to using application/xml. We'll explicitly select the formatter for this controller.
objectResult.Formatters.Add(new XmlSerializerOutputFormatter());
}
}
}
}