diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ProblemDetails.cs b/src/Microsoft.AspNetCore.Mvc.Core/ProblemDetails.cs index 573419b0bc..44b816aa05 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ProblemDetails.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ProblemDetails.cs @@ -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". /// + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public string Type { get; set; } /// - /// 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). /// + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public string Title { get; set; } /// /// The HTTP status code([RFC7231], Section 6) generated by the origin server for this occurrence of the problem. /// + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public int? Status { get; set; } /// /// A human-readable explanation specific to this occurrence of the problem. /// + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public string Detail { get; set; } /// - /// 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. /// + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public string Instance { get; set; } + + /// + /// Gets the for extension members. + /// + /// 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. + /// + /// + /// + /// The round-tripping behavior for 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. + /// + [JsonExtensionData] + public IDictionary Extensions { get; } = new Dictionary(StringComparer.Ordinal); } } diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ProblemDetailsWrapper.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ProblemDetailsWrapper.cs new file mode 100644 index 0000000000..30775bd1b1 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ProblemDetailsWrapper.cs @@ -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 +{ + /// + /// Wrapper class for to enable it to be serialized by the xml formatters. + /// + [XmlRoot(nameof(ProblemDetails))] + public class ProblemDetailsWrapper : IXmlSerializable, IUnwrappable + { + /// + /// Key used to represent dictionary elements with empty keys + /// + protected static readonly string EmptyKey = SerializableErrorWrapper.EmptyKey; + + /// + /// Initializes a new instance of . + /// + public ProblemDetailsWrapper() + : this(new ProblemDetails()) + { + } + + /// + /// Initializes a new instance of . + /// + public ProblemDetailsWrapper(ProblemDetails problemDetails) + { + ProblemDetails = problemDetails; + } + + internal ProblemDetails ProblemDetails { get; } + + /// + public XmlSchema GetSchema() => null; + + /// + 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(); + } + + /// + /// Reads the value for the specified from the . + /// + /// The . + /// The name of the node. + 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; + } + } + + /// + 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; + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..61797e230b --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/Properties/AssemblyInfo.cs @@ -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")] diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/SerializableErrorWrapper.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/SerializableErrorWrapper.cs index e8a28f576f..8a72825a80 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/SerializableErrorWrapper.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/SerializableErrorWrapper.cs @@ -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() diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ValidationProblemDetailsWrapper.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ValidationProblemDetailsWrapper.cs new file mode 100644 index 0000000000..b8787ee0e0 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/ValidationProblemDetailsWrapper.cs @@ -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 +{ + /// + /// Wrapper class for to enable it to be serialized by the xml formatters. + /// + [XmlRoot(nameof(ValidationProblemDetails))] + public class ValidationProblemDetailsWrapper : ProblemDetailsWrapper, IUnwrappable + { + private static readonly string ErrorKey = "MVC-Errors"; + + /// + /// Initializes a new instance of . + /// + public ValidationProblemDetailsWrapper() + : this(new ValidationProblemDetails()) + { + } + + /// + /// Initializes a new instance of for the specified + /// . + /// + /// The . + public ValidationProblemDetailsWrapper(ValidationProblemDetails problemDetails) + : base(problemDetails) + { + ProblemDetails = problemDetails; + } + + internal new ValidationProblemDetails ProblemDetails { get; } + + /// + 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(); + } + } + + /// + 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; + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/WrapperProviderFactoriesExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/WrapperProviderFactoriesExtensions.cs index 1ccea1645d..6ff62a8ec0 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/WrapperProviderFactoriesExtensions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/WrapperProviderFactoriesExtensions.cs @@ -44,5 +44,24 @@ namespace Microsoft.AspNetCore.Mvc.Formatters.Xml return null; } + + internal static IList GetDefaultProviderFactories() + { + var wrapperProviderFactories = new List(); + + 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; + } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/WrapperProviderFactory.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/WrapperProviderFactory.cs new file mode 100644 index 0000000000..3f7c4a48af --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/WrapperProviderFactory.cs @@ -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 wrapper) + { + DeclaredType = declaredType; + WrappingType = wrappingType; + Wrapper = wrapper; + } + + public Type DeclaredType { get; } + + public Type WrappingType { get; } + + public Func 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); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerInputFormatter.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerInputFormatter.cs index 7916715002..b5d6d74a22 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerInputFormatter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerInputFormatter.cs @@ -46,8 +46,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters _serializerSettings = new DataContractSerializerSettings(); - WrapperProviderFactories = new List(); - WrapperProviderFactories.Add(new SerializableErrorWrapperProviderFactory()); + WrapperProviderFactories = WrapperProviderFactoriesExtensions.GetDefaultProviderFactories(); } /// diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerOutputFormatter.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerOutputFormatter.cs index a0f374cc27..6a312d903d 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerOutputFormatter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlDataContractSerializerOutputFormatter.cs @@ -76,9 +76,8 @@ namespace Microsoft.AspNetCore.Mvc.Formatters _serializerSettings = new DataContractSerializerSettings(); - WrapperProviderFactories = new List(); + WrapperProviderFactories = WrapperProviderFactoriesExtensions.GetDefaultProviderFactories(); WrapperProviderFactories.Add(new EnumerableWrapperProviderFactory(WrapperProviderFactories)); - WrapperProviderFactories.Add(new SerializableErrorWrapperProviderFactory()); _logger = loggerFactory?.CreateLogger(GetType()); } diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerInputFormatter.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerInputFormatter.cs index 6708c81257..2c1ea40bbd 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerInputFormatter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerInputFormatter.cs @@ -43,8 +43,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters SupportedMediaTypes.Add(MediaTypeHeaderValues.TextXml); SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationAnyXmlSyntax); - WrapperProviderFactories = new List(); - WrapperProviderFactories.Add(new SerializableErrorWrapperProviderFactory()); + WrapperProviderFactories = WrapperProviderFactoriesExtensions.GetDefaultProviderFactories(); } /// diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerOutputFormatter.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerOutputFormatter.cs index c71be18264..e24b2c9d20 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerOutputFormatter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Xml/XmlSerializerOutputFormatter.cs @@ -73,9 +73,8 @@ namespace Microsoft.AspNetCore.Mvc.Formatters WriterSettings = writerSettings; - WrapperProviderFactories = new List(); + WrapperProviderFactories = WrapperProviderFactoriesExtensions.GetDefaultProviderFactories(); WrapperProviderFactories.Add(new EnumerableWrapperProviderFactory(WrapperProviderFactories)); - WrapperProviderFactories.Add(new SerializableErrorWrapperProviderFactory()); _logger = loggerFactory?.CreateLogger(GetType()); } diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ProblemDetailsWrapperTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ProblemDetailsWrapperTest.cs new file mode 100644 index 0000000000..b6760f0f15 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ProblemDetailsWrapperTest.cs @@ -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 = "" + + "" + + "Some title" + + "403" + + "Some instance" + + "Test Value 1" + + "<_x005B_key2_x005D_>Test Value 2" + + "Test Value 3" + + ""; + var serializer = new DataContractSerializer(typeof(ProblemDetailsWrapper)); + + // Act + var value = serializer.ReadObject( + new MemoryStream(Encoding.UTF8.GetBytes(xml))); + + // Assert + var problemDetails = Assert.IsType(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 = "" + + "" + + "Some detail" + + "Some title" + + "Test Value 1" + + "<_x005B_Key2_x005D_>Test Value 2" + + "Test Value 3" + + ""; + + // 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); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ValidationProblemDetailsWrapperTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ValidationProblemDetailsWrapperTest.cs new file mode 100644 index 0000000000..50630a0082 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/ValidationProblemDetailsWrapperTest.cs @@ -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 = "" + + "" + + "Some title" + + "400" + + "Some instance" + + "Test Value 1" + + "<_x005B_key2_x005D_>Test Value 2" + + "" + + "Test error 1 Test error 2" + + "<_x005B_error2_x005D_>Test error 3" + + "Test error 4" + + "" + + ""; + var serializer = new DataContractSerializer(typeof(ValidationProblemDetailsWrapper)); + + // Act + var value = serializer.ReadObject( + new MemoryStream(Encoding.UTF8.GetBytes(xml))); + + // Assert + var problemDetails = Assert.IsType(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 = "" + + "" + + "Some title" + + "400" + + "Some instance" + + "Test Value 1" + + "<_x005B_key2_x005D_>Test Value 2" + + ""; + var serializer = new DataContractSerializer(typeof(ValidationProblemDetailsWrapper)); + + // Act + var value = serializer.ReadObject( + new MemoryStream(Encoding.UTF8.GetBytes(xml))); + + // Assert + var problemDetails = Assert.IsType(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 = "" + + "" + + "Some title" + + "400" + + "" + + ""; + var serializer = new DataContractSerializer(typeof(ValidationProblemDetailsWrapper)); + + // Act + var value = serializer.ReadObject( + new MemoryStream(Encoding.UTF8.GetBytes(xml))); + + // Assert + var problemDetails = Assert.IsType(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 = "" + + "" + + "Some detail" + + "Some title" + + "Test Value 1" + + "<_x005B_Key2_x005D_>Test Value 2" + + "" + + "Test error 1 Test error 2" + + "<_x005B_error2_x005D_>Test error 3" + + "Test error 4" + + "" + + ""; + + // 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 = "" + + "" + + "Some detail" + + "Some title" + + "Test Value 1" + + "<_x005B_Key2_x005D_>Test Value 2" + + ""; + + // 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); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/WrapperProviderFactoryExtensionsTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/WrapperProviderFactoryExtensionsTest.cs new file mode 100644 index 0000000000..ca981f679a --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/WrapperProviderFactoryExtensionsTest.cs @@ -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(factory), + factory => + { + var wrapperProviderFactory = Assert.IsType(factory); + Assert.Equal(typeof(ProblemDetails), wrapperProviderFactory.DeclaredType); + Assert.Equal(typeof(ProblemDetailsWrapper), wrapperProviderFactory.WrappingType); + }, + factory => + { + var wrapperProviderFactory = Assert.IsType(factory); + Assert.Equal(typeof(ValidationProblemDetails), wrapperProviderFactory.DeclaredType); + Assert.Equal(typeof(ValidationProblemDetailsWrapper), wrapperProviderFactory.WrappingType); + }); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/WrapperProviderFactoryTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/WrapperProviderFactoryTest.cs new file mode 100644 index 0000000000..c067ed4ab9 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Xml.Test/WrapperProviderFactoryTest.cs @@ -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); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiBehaviorTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiBehaviorTest.cs index d3ec4d9e29..127d612fdd 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiBehaviorTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiBehaviorTest.cs @@ -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 { - {"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(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(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); + }); + } } } diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/HttpClientExtensions.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/HttpClientExtensions.cs index 94e52c7e14..184e83cd31 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/HttpClientExtensions.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Infrastructure/HttpClientExtensions.cs @@ -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; } } } diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlDataContractSerializerFormattersWrappingTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlDataContractSerializerFormattersWrappingTest.cs index 5e6443a862..9ee16921a6 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlDataContractSerializerFormattersWrappingTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlDataContractSerializerFormattersWrappingTest.cs @@ -208,5 +208,68 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests "", result); } + + [Fact] + public async Task ProblemDetails_IsSerialized() + { + // Arrange + var expected = @"404Not Foundhttps://tools.ietf.org/html/rfc7231#section-6.5.4"; + + // 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 = @"instance404title +correlationAccount1 Account2"; + + // 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 = @"400One or more validation errors occurred. +The State field is required."; + + // 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 = @"some detail400One or more validation errors occurred. +some typecorrelationErrorValue"; + + // 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); + } } -} \ No newline at end of file +} diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlSerializerFormattersWrappingTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlSerializerFormattersWrappingTest.cs index 2b8ddd9a06..d54cd3880c 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlSerializerFormattersWrappingTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/XmlSerializerFormattersWrappingTest.cs @@ -183,5 +183,68 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests "key2-error", result); } + + [Fact] + public async Task ProblemDetails_IsSerialized() + { + // Arrange + var expected = @"404Not Foundhttps://tools.ietf.org/html/rfc7231#section-6.5.4"; + + // 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 = @"instance404title +correlationAccount1 Account2"; + + // 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 = @"400One or more validation errors occurred. +The State field is required."; + + // 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 = @"some detail400One or more validation errors occurred. +some typecorrelationErrorValue"; + + // 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); + } } } diff --git a/test/WebSites/BasicWebSite/Controllers/ContactApiController.cs b/test/WebSites/BasicWebSite/Controllers/ContactApiController.cs index a2cd22afc5..c2d3400e1f 100644 --- a/test/WebSites/BasicWebSite/Controllers/ContactApiController.cs +++ b/test/WebSites/BasicWebSite/Controllers/ContactApiController.cs @@ -91,6 +91,41 @@ namespace BasicWebSite return NotFound(); } + [HttpGet("[action]")] + public ActionResult ActionReturningProblemDetails() + { + return NotFound(new ProblemDetails + { + Title = "Not Found", + Type = "Type", + Detail = "Detail", + Status = 404, + Instance = "Instance", + Extensions = + { + ["tracking-id"] = 27, + }, + }); + } + + [HttpGet("[action]")] + public ActionResult 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) diff --git a/test/WebSites/XmlFormattersWebSite/Controllers/XmlApiControllerBase.cs b/test/WebSites/XmlFormattersWebSite/Controllers/XmlApiControllerBase.cs new file mode 100644 index 0000000000..5296428144 --- /dev/null +++ b/test/WebSites/XmlFormattersWebSite/Controllers/XmlApiControllerBase.cs @@ -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 ActionReturningClientErrorStatusCodeResult() + => NotFound(); + + [HttpGet] + public ActionResult ActionReturningProblemDetails() + { + return NotFound(new ProblemDetails + { + Instance = "instance", + Title = "title", + Extensions = + { + ["Correlation"] = "correlation", + ["Accounts"] = new[] { "Account1", "Account2" }, + }, + }); + } + + [HttpGet] + public ActionResult ActionReturningValidationProblem([FromQuery] Address address) + => throw new NotImplementedException(); + + [HttpGet] + public ActionResult ActionReturningValidationDetailsWithMetadata() + { + return new BadRequestObjectResult(new ValidationProblemDetails + { + Detail = "some detail", + Type = "some type", + Extensions = + { + ["CorrelationId"] = "correlation", + }, + Errors = + { + ["Error1"] = new[] { "ErrorValue"}, + }, + }); + } + } +} diff --git a/test/WebSites/XmlFormattersWebSite/Controllers/XmlDataContractApiController.cs b/test/WebSites/XmlFormattersWebSite/Controllers/XmlDataContractApiController.cs new file mode 100644 index 0000000000..dd8f228caa --- /dev/null +++ b/test/WebSites/XmlFormattersWebSite/Controllers/XmlDataContractApiController.cs @@ -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()); + } + } + } +} \ No newline at end of file diff --git a/test/WebSites/XmlFormattersWebSite/Controllers/XmlSerializedApiController.cs b/test/WebSites/XmlFormattersWebSite/Controllers/XmlSerializedApiController.cs new file mode 100644 index 0000000000..6ee3ec4708 --- /dev/null +++ b/test/WebSites/XmlFormattersWebSite/Controllers/XmlSerializedApiController.cs @@ -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()); + } + } + } +} \ No newline at end of file