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