diff --git a/src/Microsoft.AspNet.Mvc.Core/ActionResults/BadRequestObjectResult.cs b/src/Microsoft.AspNet.Mvc.Core/ActionResults/BadRequestObjectResult.cs new file mode 100644 index 0000000000..e1b3481a5d --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/ActionResults/BadRequestObjectResult.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Mvc.ModelBinding; + +namespace Microsoft.AspNet.Mvc +{ + /// + /// An that when executed will produce a Bad Request (400) response. + /// + public class BadRequestObjectResult : ObjectResult + { + /// + /// Creates a new instance. + /// + /// Contains the errors to be returned to the client. + public BadRequestObjectResult(object error) + : base(error) + { + StatusCode = 400; + } + + /// + /// Creates a new instance. + /// + /// containing the validation errors. + public BadRequestObjectResult([NotNull] ModelStateDictionary modelState) + : base(new SerializableError(modelState)) + { + StatusCode = 400; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/ActionResults/SerializableError.cs b/src/Microsoft.AspNet.Mvc.Core/ActionResults/SerializableError.cs new file mode 100644 index 0000000000..eb37279893 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/ActionResults/SerializableError.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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.Collections.Generic; +using System.Linq; +using System.Xml; +using System.Xml.Schema; +using System.Xml.Serialization; +using Microsoft.AspNet.Mvc.Core; +using Microsoft.AspNet.Mvc.ModelBinding; + +namespace Microsoft.AspNet.Mvc +{ + /// + /// Defines a serializable container for storing ModelState information. + /// This information is stored as key/value pairs. + /// + [XmlRoot("Error")] + public sealed class SerializableError : Dictionary, IXmlSerializable + { + /// + /// Initializes a new instance of the class. + /// + public SerializableError() + : base(StringComparer.OrdinalIgnoreCase) + { + } + + /// + /// Creates a new instance of . + /// + /// containing the validation errors. + public SerializableError([NotNull] ModelStateDictionary modelState) + : this() + { + if (modelState.IsValid) + { + return; + } + + foreach (var keyModelStatePair in modelState) + { + var key = keyModelStatePair.Key; + var errors = keyModelStatePair.Value.Errors; + if (errors != null && errors.Count > 0) + { + var errorMessages = errors.Select(error => + { + return string.IsNullOrEmpty(error.ErrorMessage) ? + Resources.SerializableError_DefaultError : error.ErrorMessage; + }).ToArray(); + + Add(key, errorMessages); + } + } + } + + // + public XmlSchema GetSchema() + { + return null; + } + + // + public void ReadXml(XmlReader reader) + { + if (reader.IsEmptyElement) + { + reader.Read(); + return; + } + + reader.ReadStartElement(); + while (reader.NodeType != XmlNodeType.EndElement) + { + var key = XmlConvert.DecodeName(reader.LocalName); + var value = reader.ReadInnerXml(); + + Add(key, value); + reader.MoveToContent(); + } + + reader.ReadEndElement(); + } + + // + public void WriteXml(XmlWriter writer) + { + foreach (var keyValuePair in this) + { + var key = keyValuePair.Key; + var value = keyValuePair.Value; + writer.WriteStartElement(XmlConvert.EncodeLocalName(key)); + if (value != null) + { + writer.WriteValue(value); + } + + writer.WriteEndElement(); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Controller.cs b/src/Microsoft.AspNet.Mvc.Core/Controller.cs index ea16319d0b..53bb774895 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Controller.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Controller.cs @@ -627,6 +627,26 @@ namespace Microsoft.AspNet.Mvc return new BadRequestResult(); } + /// + /// Creates an that produces a Bad Request (400) response. + /// + /// The created for the response. + [NonAction] + public virtual BadRequestObjectResult HttpBadRequest(object error) + { + return new BadRequestObjectResult(error); + } + + /// + /// Creates an that produces a Bad Request (400) response. + /// + /// The created for the response. + [NonAction] + public virtual BadRequestObjectResult HttpBadRequest([NotNull] ModelStateDictionary modelState) + { + return new BadRequestObjectResult(modelState); + } + /// /// Creates a object that produces a Created (201) response. /// diff --git a/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs b/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs index 714664f8df..4b20727882 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs @@ -1514,6 +1514,22 @@ namespace Microsoft.AspNet.Mvc.Core return string.Format(CultureInfo.CurrentCulture, GetString("ViewComponent_AmbiguousTypeMatch_Item"), p0, p1); } + /// + /// The input was not valid. + /// + internal static string SerializableError_DefaultError + { + get { return GetString("SerializableError_DefaultError"); } + } + + /// + /// The input was not valid. + /// + internal static string FormatSerializableError_DefaultError() + { + return GetString("SerializableError_DefaultError"); + } + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.AspNet.Mvc.Core/Resources.resx b/src/Microsoft.AspNet.Mvc.Core/Resources.resx index 87355a2b09..b9fb2fe066 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Resources.resx +++ b/src/Microsoft.AspNet.Mvc.Core/Resources.resx @@ -1,17 +1,17 @@  - @@ -409,4 +409,7 @@ Type: '{0}' - Name: '{1}' + + The input was not valid. + \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ActionResults/BadRequestObjectResultTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ActionResults/BadRequestObjectResultTests.cs new file mode 100644 index 0000000000..775a899e13 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ActionResults/BadRequestObjectResultTests.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit; +using Microsoft.AspNet.Mvc.ModelBinding; + +namespace Microsoft.AspNet.Mvc +{ + public class BadRequestObjectResultTests + { + [Fact] + public void BadRequestObjectResult_SetsStatusCodeAndValue() + { + // Arrange & Act + var obj = new object(); + var badRequestObjecResult = new BadRequestObjectResult(obj); + + // Assert + Assert.Equal(400, badRequestObjecResult.StatusCode); + Assert.Equal(obj, badRequestObjecResult.Value); + } + + [Fact] + public void BadRequestObjectResult_ModelState_SetsStatusCodeAndValue() + { + // Arrange & Act + var badRequestObjecResult = new BadRequestObjectResult(new ModelStateDictionary()); + + // Assert + Assert.Equal(400, badRequestObjecResult.StatusCode); + var errors = Assert.IsType(badRequestObjecResult.Value); + Assert.Equal(0, errors.Count); + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ActionResults/SerializableErrorTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ActionResults/SerializableErrorTests.cs new file mode 100644 index 0000000000..67f59c6518 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ActionResults/SerializableErrorTests.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Globalization; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Xml; +using Microsoft.AspNet.Mvc.ModelBinding; +using Xunit; + +namespace Microsoft.AspNet.Mvc +{ + public class SerializableErrorTests + { + [Fact] + public void ConvertsModelState_To_Dictionary() + { + // Arrange + var modelState = new ModelStateDictionary(); + modelState.AddModelError("key1", "Test Error 1"); + modelState.AddModelError("key1", "Test Error 2"); + modelState.AddModelError("key2", "Test Error 3"); + + // Act + var serializableError = new SerializableError(modelState); + + // Assert + var arr = Assert.IsType(serializableError["key1"]); + Assert.Equal("Test Error 1", arr[0]); + Assert.Equal("Test Error 2", arr[1]); + Assert.Equal("Test Error 3", (serializableError["key2"] as string[])[0]); + } + + [Fact] + public void LookupIsCaseInsensitive() + { + // Arrange + var modelState = new ModelStateDictionary(); + modelState.AddModelError("key1", "x"); + + // Act + var serializableError = new SerializableError(modelState); + + // Assert + var arr = Assert.IsType(serializableError["KEY1"]); + Assert.Equal("x", arr[0]); + } + + [Fact] + public void ConvertsModelState_To_Dictionary_AddsDefaultValuesWhenErrorsAreAbsent() + { + // Arrange + var modelState = new ModelStateDictionary(); + modelState.AddModelError("key1", ""); + + // Act + var serializableError = new SerializableError(modelState); + + // Assert + var arr = Assert.IsType(serializableError["key1"]); + Assert.Equal("The input was not valid.", arr[0]); + } + + [Fact] + public void DoesNotThrowOnValidModelState() + { + // Arrange, Act & Assert (does not throw) + new SerializableError(new ModelStateDictionary()); + } + + [Fact] + public void DoesNotAddEntries_IfNoErrorsArePresent() + { + // Arrange + var modelState = new ModelStateDictionary(); + modelState.Add( + "key1", + new ModelState() { Value = new ValueProviderResult("foo", "foo", CultureInfo.InvariantCulture) }); + modelState.Add( + "key2", + new ModelState() { Value = new ValueProviderResult("bar", "bar", CultureInfo.InvariantCulture) }); + + // Act + var serializableError = new SerializableError(modelState); + + // Assert + Assert.Equal(0, serializableError.Count); + } + + [Fact] + public void GetSchema_Returns_Null() + { + // Arrange + var modelState = new ModelStateDictionary(); + // To make modelState invalid. + modelState.AddModelError("key1", "Test Error 1"); + var serializableError = new SerializableError(modelState); + + // Act & Assert + Assert.Null(serializableError.GetSchema()); + } + + [Fact] + public void WriteXml_WritesValidXml() + { + // Arrange + var modelState = new ModelStateDictionary(); + modelState.AddModelError("key1", "Test Error 1"); + modelState.AddModelError("key1", "Test Error 2"); + modelState.AddModelError("key2", "Test Error 3"); + var serializableError = new SerializableError(modelState); + var outputStream = new MemoryStream(); + + // Act + using (var xmlWriter = XmlWriter.Create(outputStream)) + { + var dataContractSerializer = new DataContractSerializer(typeof(SerializableError)); + dataContractSerializer.WriteObject(xmlWriter, serializableError); + } + outputStream.Position = 0; + var res = new StreamReader(outputStream, Encoding.UTF8).ReadToEnd(); + + // Assert + Assert.Equal("" + + "Test Error 1 Test Error 2Test Error 3", res); + } + + [Fact] + public void ReadXml_ReadsSerializableErrorXml() + { + // Arrange + var serializableErrorXml = "" + + "Test Error 1 Test Error 2Test Error 3"; + var serializer = new DataContractSerializer(typeof(SerializableError)); + + // Act + var errors = (SerializableError)serializer.ReadObject( + new MemoryStream(Encoding.UTF8.GetBytes(serializableErrorXml))); + + // Assert + Assert.Equal("Test Error 1 Test Error 2", errors["key1"]); + Assert.Equal("Test Error 3", errors["key2"]); + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ControllerTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ControllerTests.cs index 55435cfc16..d6ee18e53a 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/ControllerTests.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ControllerTests.cs @@ -669,6 +669,38 @@ namespace Microsoft.AspNet.Mvc.Test Assert.Equal(400, result.StatusCode); } + [Fact] + public void BadRequest_SetsStatusCodeAndValue_Object() + { + // Arrange + var controller = new Controller(); + var obj = new object(); + + // Act + var result = controller.HttpBadRequest(obj); + + // Assert + Assert.IsType(result); + Assert.Equal(400, result.StatusCode); + Assert.Equal(obj, result.Value); + } + + [Fact] + public void BadRequest_SetsStatusCodeAndValue_ModelState() + { + // Arrange + var controller = new Controller(); + + // Act + var result = controller.HttpBadRequest(new ModelStateDictionary()); + + // Assert + Assert.IsType(result); + Assert.Equal(400, result.StatusCode); + var errors = Assert.IsType(result.Value); + Assert.Equal(0, errors.Count); + } + [Theory] [MemberData(nameof(PublicNormalMethodsFromController))] public void NonActionAttribute_IsOnEveryPublicNormalMethodFromController(MethodInfo method) diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/ActionResultTests.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/ActionResultTests.cs index 5e9111a91c..7ff7a5170a 100644 --- a/test/Microsoft.AspNet.Mvc.FunctionalTests/ActionResultTests.cs +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/ActionResultTests.cs @@ -2,10 +2,13 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.IO; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; using System.Text; using System.Threading.Tasks; +using System.Xml.Serialization; using ActionResultsWebSite; using Microsoft.AspNet.Builder; using Microsoft.AspNet.TestHost; @@ -17,7 +20,10 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests { private readonly IServiceProvider _provider = TestHelper.CreateServices("ActionResultsWebSite"); private readonly Action _app = new Startup().Configure; - + private const string sampleIntError = "The field SampleInt must be between 10 and 100."; + private const string sampleStringError = + "The field SampleString must be a string or array type with a minimum length of '15'."; + [Fact] public async Task BadRequestResult_CanBeReturned() { @@ -181,5 +187,97 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests Assert.Equal("http://localhost/foo/ActionResultsVerification/GetDummy/1", response.Headers.Location.OriginalString); Assert.Equal("{\"SampleInt\":10,\"SampleString\":\"Foo\"}", await response.Content.ReadAsStringAsync()); } + + [Theory] + [InlineData("http://localhost/Home/Index", + "application/json;charset=utf-8", + "{\"test.SampleInt\":[\"" + sampleIntError + "\"]," + + "\"test.SampleString\":" + + "[\"" + sampleStringError + "\"]}")] + [InlineData("http://localhost/Home/Index", + "application/xml;charset=utf-8", + "" + sampleIntError + "" + + "" + sampleStringError + + "")] + [InlineData("http://localhost/XmlSerializer/GetSerializableError", + "application/xml;charset=utf-8", + "" + sampleIntError + "" + + "" + sampleStringError + + "")] + public async Task SerializableErrorIsReturnedInExpectedFormat(string url, string outputFormat, string output) + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + var input = "" + + "" + + "2foo"; + var request = new HttpRequestMessage(HttpMethod.Post, url); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(outputFormat)); + request.Content = new StringContent(input, Encoding.UTF8, "application/xml"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(output, await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task SerializableError_CanSerializeNormalObjects() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + var input = "" + + "" + + "2foo"; + var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost/Home/GetCustomErrorObject"); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;charset=utf-8")); + request.Content = new StringContent(input, Encoding.UTF8, "application/xml"); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("[\"Something went wrong with the model.\"]", + await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task SerializableError_ReadTheReturnedXml() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + var input = "" + + "" + + "20foo"; + + var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost/Home/Index"); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/xml;charset=utf-8")); + request.Content = new StringContent(input, Encoding.UTF8, "application/xml"); + + // Act + var response = await client.SendAsync(request); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Deserializing Xml content + var serializer = new XmlSerializer(typeof(SerializableError)); + var errors = (SerializableError)serializer.Deserialize( + new MemoryStream(Encoding.UTF8.GetBytes(responseContent))); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal( + "" + sampleStringError + "", + responseContent); + Assert.Equal(sampleStringError, errors["test.SampleString"]); + } } } \ No newline at end of file diff --git a/test/WebSites/ActionResultsWebSite/Controllers/HomeController.cs b/test/WebSites/ActionResultsWebSite/Controllers/HomeController.cs new file mode 100644 index 0000000000..5fdac34738 --- /dev/null +++ b/test/WebSites/ActionResultsWebSite/Controllers/HomeController.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.AspNet.Mvc; + +namespace ActionResultsWebSite +{ + public class HomeController : Controller + { + public IActionResult Index([FromBody] DummyClass test) + { + if (!ModelState.IsValid) + { + return HttpBadRequest(ModelState); + } + + return Content("Hello World!"); + } + + public IActionResult GetCustomErrorObject([FromBody] DummyClass test) + { + if (!ModelState.IsValid) + { + var errors = new List(); + errors.Add("Something went wrong with the model."); + return new BadRequestObjectResult(errors); + } + + return Content("Hello World!"); + } + } +} \ No newline at end of file diff --git a/test/WebSites/ActionResultsWebSite/Controllers/XmlSerializerController.cs b/test/WebSites/ActionResultsWebSite/Controllers/XmlSerializerController.cs new file mode 100644 index 0000000000..fe500bb0ca --- /dev/null +++ b/test/WebSites/ActionResultsWebSite/Controllers/XmlSerializerController.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Mvc; + +namespace ActionResultsWebSite +{ + public class XmlSerializerController : Controller + { + public IActionResult GetSerializableError([FromBody] DummyClass test) + { + if (!ModelState.IsValid) + { + return HttpBadRequest(ModelState); + } + + return Content("Success!"); + } + + public override void OnActionExecuted(ActionExecutedContext context) + { + var result = context.Result as ObjectResult; + if (result != null) + { + result.Formatters.Add(new XmlSerializerOutputFormatter()); + } + + base.OnActionExecuted(context); + } + } +} \ No newline at end of file diff --git a/test/WebSites/ActionResultsWebSite/Models/DummyClass.cs b/test/WebSites/ActionResultsWebSite/Models/DummyClass.cs index f9a08ad3dd..cf3ea7e377 100644 --- a/test/WebSites/ActionResultsWebSite/Models/DummyClass.cs +++ b/test/WebSites/ActionResultsWebSite/Models/DummyClass.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.ComponentModel.DataAnnotations; @@ -7,8 +7,10 @@ namespace ActionResultsWebSite { public class DummyClass { + [Range(10, 100)] public int SampleInt { get; set; } + [MinLength(15)] public string SampleString { get; set; } } } \ No newline at end of file