diff --git a/src/Microsoft.AspNet.Mvc.Core/ActionResults/JsonResult.cs b/src/Microsoft.AspNet.Mvc.Core/ActionResults/JsonResult.cs index 4ed6f1e06d..2f598152f0 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ActionResults/JsonResult.cs +++ b/src/Microsoft.AspNet.Mvc.Core/ActionResults/JsonResult.cs @@ -9,104 +9,124 @@ namespace Microsoft.AspNet.Mvc { + /// + /// An action result which formats the given object as JSON. + /// public class JsonResult : ActionResult { - private static readonly IList _defaultSupportedContentTypes = - new List() - { - MediaTypeHeaderValue.Parse("application/json"), - MediaTypeHeaderValue.Parse("text/json"), - }; - private IOutputFormatter _defaultFormatter; - - private ObjectResult _objectResult; - - public JsonResult(object data) : - this(data, null) + /// + /// The list of content-types used for formatting when is null or empty. + /// + public static readonly IReadOnlyList DefaultContentTypes = new MediaTypeHeaderValue[] { - } + MediaTypeHeaderValue.Parse("application/json"), + MediaTypeHeaderValue.Parse("text/json"), + }; /// - /// Creates an instance of class. + /// Creates a new with the given . /// - /// - /// If no matching formatter is found, - /// the response is written to using defaultFormatter. - /// - /// The default formatter must be able to handle either application/json - /// or text/json. - /// - public JsonResult(object data, IOutputFormatter defaultFormatter) + /// The value to format as JSON. + public JsonResult(object value) + : this(value, formatter: null) { - _defaultFormatter = defaultFormatter; - _objectResult = new ObjectResult(data); } - public object Value + /// + /// Creates a new with the given . + /// + /// The value to format as JSON. + /// The formatter to use, or null to choose a formatter dynamically. + public JsonResult(object value, IOutputFormatter formatter) { - get - { - return _objectResult.Value; - } - set - { - _objectResult.Value = value; - } - } + Value = value; + Formatter = formatter; - public IList ContentTypes - { - get - { - return _objectResult.ContentTypes; - } - set - { - _objectResult.ContentTypes = value; - } + ContentTypes = new List(); } + /// + /// Gets or sets the list of supported Content-Types. + /// + public IList ContentTypes { get; set; } + + /// + /// Gets or sets the formatter. + /// + public IOutputFormatter Formatter { get; set; } + + /// + /// Gets or sets the value to be formatted. + /// + public object Value { get; set; } + + /// public override async Task ExecuteResultAsync([NotNull] ActionContext context) { + var objectResult = new ObjectResult(Value); + // Set the content type explicitly to application/json and text/json. // if the user has not already set it. if (ContentTypes == null || ContentTypes.Count == 0) { - ContentTypes = _defaultSupportedContentTypes; + foreach (var contentType in DefaultContentTypes) + { + objectResult.ContentTypes.Add(contentType); + } + } + else + { + objectResult.ContentTypes = ContentTypes; } var formatterContext = new OutputFormatterContext() { - DeclaredType = _objectResult.DeclaredType, ActionContext = context, + DeclaredType = objectResult.DeclaredType, Object = Value, }; - // Need to call this instead of directly calling _objectResult.ExecuteResultAsync - // as that sets the status to 406 if a formatter is not found. - // this can be cleaned up after https://github.com/aspnet/Mvc/issues/941 gets resolved. - var formatter = SelectFormatter(formatterContext); + var formatter = SelectFormatter(objectResult, formatterContext); await formatter.WriteAsync(formatterContext); } - private IOutputFormatter SelectFormatter(OutputFormatterContext formatterContext) + private IOutputFormatter SelectFormatter(ObjectResult objectResult, OutputFormatterContext formatterContext) { - var defaultFormatters = formatterContext.ActionContext - .HttpContext - .RequestServices - .GetRequiredService() - .OutputFormatters; - - var formatter = _objectResult.SelectFormatter(formatterContext, defaultFormatters); - if (formatter == null) + if (Formatter == null) { - formatter = _defaultFormatter ?? formatterContext.ActionContext - .HttpContext - .RequestServices - .GetRequiredService(); - } + // If no formatter was provided, then run Conneg with the formatters configured in options. + var formatters = formatterContext + .ActionContext + .HttpContext + .RequestServices + .GetRequiredService() + .OutputFormatters + .OfType() + .ToArray(); + + var formatter = objectResult.SelectFormatter(formatterContext, formatters); + if (formatter == null) + { + // If the available user-configured formatters can't write this type, then fall back to the + // 'global' one. + formatter = formatterContext + .ActionContext + .HttpContext + .RequestServices + .GetRequiredService(); - return formatter; + // Run SelectFormatter again to try to choose a content type that this formatter can do. + objectResult.SelectFormatter(formatterContext, new[] { formatter }); + } + + return formatter; + } + else + { + // Run SelectFormatter to try to choose a content type that this formatter can do. + objectResult.SelectFormatter(formatterContext, new[] { Formatter }); + return Formatter; + } } } } diff --git a/src/Microsoft.AspNet.Mvc.Core/Formatters/IJsonOutputFormatter.cs b/src/Microsoft.AspNet.Mvc.Core/Formatters/IJsonOutputFormatter.cs new file mode 100644 index 0000000000..db86820470 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Formatters/IJsonOutputFormatter.cs @@ -0,0 +1,20 @@ +// 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. + +namespace Microsoft.AspNet.Mvc +{ + /// + /// An output formatter that specializes in writing JSON content. + /// + /// + /// The class filter the collection of + /// and use only those which implement + /// . + /// + /// To create a custom formatter that can be used by , derive from + /// or implement . + /// + public interface IJsonOutputFormatter : IOutputFormatter + { + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Formatters/JsonOutputFormatter.cs b/src/Microsoft.AspNet.Mvc.Core/Formatters/JsonOutputFormatter.cs index 1a017f92d5..55dc17c7e2 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Formatters/JsonOutputFormatter.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Formatters/JsonOutputFormatter.cs @@ -9,7 +9,7 @@ namespace Microsoft.AspNet.Mvc { - public class JsonOutputFormatter : OutputFormatter + public class JsonOutputFormatter : OutputFormatter, IJsonOutputFormatter { private JsonSerializerSettings _serializerSettings; diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/JsonResultTest.cs b/test/Microsoft.AspNet.Mvc.Core.Test/JsonResultTest.cs index d09c1c12e5..670ce70b67 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/JsonResultTest.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/JsonResultTest.cs @@ -9,11 +9,11 @@ using System.Threading.Tasks; using Microsoft.AspNet.Http; using Microsoft.AspNet.Mvc.HeaderValueAbstractions; +using Microsoft.AspNet.PipelineCore; using Microsoft.AspNet.Routing; using Moq; using Xunit; - namespace Microsoft.AspNet.Mvc { public class JsonResultTest @@ -22,114 +22,192 @@ private static readonly byte[] _abcdUTF8Bytes = new byte[] { 123, 34, 102, 111, 111, 34, 58, 34, 97, 98, 99, 100, 34, 125 }; [Fact] - public async Task ExecuteResult_GeneratesResultsWithoutBOMByDefault() + public async Task ExecuteResultAsync_OptionsFormatter_WithoutBOM() { // Arrange var expected = _abcdUTF8Bytes; - var memoryStream = new MemoryStream(); - var response = new Mock(); - response.SetupGet(r => r.Body) - .Returns(memoryStream); - var context = GetHttpContext(response.Object); - var actionContext = new ActionContext(context, - new RouteData(), - new ActionDescriptor()); + + var optionsFormatters = new List() + { + new XmlDataContractSerializerOutputFormatter(), // This won't be used + new JsonOutputFormatter(), + }; + + var context = GetHttpContext(optionsFormatters); + var actionContext = new ActionContext(context, new RouteData(), new ActionDescriptor()); + var result = new JsonResult(new { foo = "abcd" }); // Act await result.ExecuteResultAsync(actionContext); + var written = GetWrittenBytes(context); // Assert - Assert.Equal(expected, memoryStream.ToArray()); + Assert.Equal(expected, written); + Assert.Equal("application/json;charset=utf-8", context.Response.ContentType); } [Fact] - public async Task ExecuteResult_IfNoMatchFoundUsesPassedInFormatter() + public async Task ExecuteResultAsync_Null() { // Arrange - var expected = Enumerable.Concat(Encoding.UTF8.GetPreamble(), _abcdUTF8Bytes); - var memoryStream = new MemoryStream(); - var response = new Mock(); - response.SetupGet(r => r.Body) - .Returns(memoryStream); - var context = GetHttpContext(response.Object, registerDefaultFormatter: false); - var actionContext = new ActionContext(context, - new RouteData(), - new ActionDescriptor()); - var testFormatter = new TestJsonFormatter() - { - Encoding = Encoding.UTF8 - }; - - var result = new JsonResult(new { foo = "abcd" }, testFormatter); + var expected = _abcdUTF8Bytes; + + var optionsFormatters = new List() + { + new XmlDataContractSerializerOutputFormatter(), // This won't be used + new JsonOutputFormatter(), + }; + + var context = GetHttpContext(optionsFormatters); + var actionContext = new ActionContext(context, new RouteData(), new ActionDescriptor()); + + var result = new JsonResult(new { foo = "abcd" }); // Act await result.ExecuteResultAsync(actionContext); + var written = GetWrittenBytes(context); // Assert - Assert.Equal(expected, memoryStream.ToArray()); + Assert.Equal(expected, written); + Assert.Equal("application/json;charset=utf-8", context.Response.ContentType); } - public async Task ExecuteResult_UsesDefaultFormatter_IfNoneIsRegistered_AndNoneIsPassed() + [Fact] + public async Task ExecuteResultAsync_OptionsFormatter_Conneg() { // Arrange var expected = _abcdUTF8Bytes; - var memoryStream = new MemoryStream(); - var response = new Mock(); - response.SetupGet(r => r.Body) - .Returns(memoryStream); - var context = GetHttpContext(response.Object, registerDefaultFormatter: false); - var actionContext = new ActionContext(context, - new RouteData(), - new ActionDescriptor()); + + var optionsFormatters = new List() + { + new XmlDataContractSerializerOutputFormatter(), // This won't be used + new JsonOutputFormatter(), + }; + + var context = GetHttpContext(optionsFormatters); + context.Request.Accept = "text/json"; + + var actionContext = new ActionContext(context, new RouteData(), new ActionDescriptor()); + var result = new JsonResult(new { foo = "abcd" }); // Act await result.ExecuteResultAsync(actionContext); + var written = GetWrittenBytes(context); // Assert - Assert.Equal(expected, memoryStream.ToArray()); + Assert.Equal(expected, written); + Assert.Equal("text/json;charset=utf-8", context.Response.ContentType); } - private HttpContext GetHttpContext(HttpResponse response, bool registerDefaultFormatter = true) + [Fact] + public async Task ExecuteResultAsync_UsesPassedInFormatter() { - var defaultFormatters = registerDefaultFormatter ? new List() { new JsonOutputFormatter() } : - new List(); - var httpContext = new Mock(); - var mockFormattersProvider = new Mock(); - mockFormattersProvider.SetupGet(o => o.OutputFormatters) - .Returns(defaultFormatters); - httpContext.Setup(o => o.RequestServices.GetService(typeof(IOutputFormattersProvider))) - .Returns(mockFormattersProvider.Object); - httpContext.SetupGet(o => o.Request.Accept) - .Returns(""); - httpContext.SetupGet(c => c.Response).Returns(response); - return httpContext.Object; + // Arrange + var expected = Enumerable.Concat(Encoding.UTF8.GetPreamble(), _abcdUTF8Bytes); + + var context = GetHttpContext(); + var actionContext = new ActionContext(context, new RouteData(), new ActionDescriptor()); + + var formatter = new JsonOutputFormatter(); + formatter.SupportedEncodings.Clear(); + + // This is UTF-8 WITH BOM + formatter.SupportedEncodings.Add(Encoding.UTF8); + + var result = new JsonResult(new { foo = "abcd" }, formatter); + + // Act + await result.ExecuteResultAsync(actionContext); + var written = GetWrittenBytes(context); + + // Assert + Assert.Equal(expected, written); + Assert.Equal("application/json;charset=utf-8", context.Response.ContentType); } - private class TestJsonFormatter : IOutputFormatter + [Fact] + public async Task ExecuteResultAsync_UsesPassedInFormatter_ContentTypeSpecified() { - public Encoding Encoding { get; set; } + // Arrange + var expected = _abcdUTF8Bytes; - public bool CanWriteResult(OutputFormatterContext context, MediaTypeHeaderValue contentType) - { - return true; - } + var context = GetHttpContext(); + var actionContext = new ActionContext(context, new RouteData(), new ActionDescriptor()); - public IReadOnlyList GetSupportedContentTypes(Type declaredType, - Type runtimeType, - MediaTypeHeaderValue contentType) - { - return null; - } + var formatter = new JsonOutputFormatter(); + formatter.SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/hal+json")); + + var result = new JsonResult(new { foo = "abcd" }, formatter); + result.ContentTypes.Add(MediaTypeHeaderValue.Parse("application/hal+json")); + + // Act + await result.ExecuteResultAsync(actionContext); + var written = GetWrittenBytes(context); + + // Assert + Assert.Equal(expected, written); + Assert.Equal("application/hal+json;charset=utf-8", context.Response.ContentType); + } + + // If no formatter in options can match the given content-types, then use the one registered + // in services + [Fact] + public async Task ExecuteResultAsync_UsesGlobalFormatter_IfNoFormatterIsConfigured() + { + // Arrange + var expected = _abcdUTF8Bytes; + + var context = GetHttpContext(enableFallback: true); + var actionContext = new ActionContext(context, new RouteData(), new ActionDescriptor()); + + var result = new JsonResult(new { foo = "abcd" }); + + // Act + await result.ExecuteResultAsync(actionContext); + var written = GetWrittenBytes(context); + + // Assert + Assert.Equal(expected, written); + Assert.Equal("application/json;charset=utf-8", context.Response.ContentType); + } + + private HttpContext GetHttpContext( + IReadOnlyList optionsFormatters = null, + bool enableFallback = false) + { + var httpContext = new DefaultHttpContext(); + httpContext.Response.Body = new MemoryStream(); - public async Task WriteAsync(OutputFormatterContext context) + var services = new Mock(MockBehavior.Strict); + httpContext.RequestServices = services.Object; + + var mockFormattersProvider = new Mock(); + mockFormattersProvider + .SetupGet(o => o.OutputFormatters) + .Returns(optionsFormatters ?? new List()); + + services + .Setup(s => s.GetService(typeof(IOutputFormattersProvider))) + .Returns(mockFormattersProvider.Object); + + // This is the ultimate fallback, it will be used if none of the formatters from options + // work. + if (enableFallback) { - // Override using the selected encoding. - context.SelectedEncoding = Encoding; - var jsonFormatter = new JsonOutputFormatter(); - await jsonFormatter.WriteResponseBodyAsync(context); + services + .Setup(s => s.GetService(typeof(JsonOutputFormatter))) + .Returns(new JsonOutputFormatter()); } + + return httpContext; + } + + private byte[] GetWrittenBytes(HttpContext context) + { + context.Response.Body.Seek(0, SeekOrigin.Begin); + return Assert.IsType(context.Response.Body).ToArray(); } } } \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/JsonResultTest.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/JsonResultTest.cs new file mode 100644 index 0000000000..64e6b527c5 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/JsonResultTest.cs @@ -0,0 +1,184 @@ +// 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.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNet.Builder; +using Microsoft.AspNet.TestHost; +using Xunit; + +namespace Microsoft.AspNet.Mvc.FunctionalTests +{ + public class JsonResultTest + { + private readonly IServiceProvider _provider = TestHelper.CreateServices(nameof(BasicWebSite)); + private readonly Action _app = new BasicWebSite.Startup().Configure; + + [Theory] + [InlineData("application/json")] + [InlineData("text/json")] + public async Task JsonResult_Conneg(string mediaType) + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + var url = "http://localhost/JsonResult/Plain"; + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.TryAddWithoutValidation("Accept", mediaType); + + // Act + var response = await client.SendAsync(request); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(mediaType, response.Content.Headers.ContentType.MediaType); + Assert.Equal("{\"Message\":\"hello\"}", content); + } + + // Using an Accept header can't force Json to not be Json. If your accept header doesn't jive with the + // formatters/content-type configured on the result it will be ignored. + [Theory] + [InlineData("application/xml")] + [InlineData("text/xml")] + public async Task JsonResult_Conneg_Fails(string mediaType) + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + var url = "http://localhost/JsonResult/Plain"; + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.TryAddWithoutValidation("Accept", mediaType); + + // Act + var response = await client.SendAsync(request); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType.MediaType); + Assert.Equal("{\"Message\":\"hello\"}", content); + } + + // If the object is null, it will get formatted as JSON. NOT as a 204/NoContent + [Fact] + public async Task JsonResult_Null() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + var url = "http://localhost/JsonResult/Null"; + + var request = new HttpRequestMessage(HttpMethod.Get, url); + + // Act + var response = await client.SendAsync(request); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType.MediaType); + Assert.Equal("null", content); + } + + // If the object is a string, it will get formatted as JSON. NOT as text/plain. + [Fact] + public async Task JsonResult_String() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + var url = "http://localhost/JsonResult/String"; + + var request = new HttpRequestMessage(HttpMethod.Get, url); + + // Act + var response = await client.SendAsync(request); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType.MediaType); + Assert.Equal("\"hello\"", content); + } + + [Theory] + [InlineData("application/json")] + [InlineData("text/json")] + public async Task JsonResult_CustomFormatter_Conneg(string mediaType) + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + var url = "http://localhost/JsonResult/CustomFormatter"; + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.TryAddWithoutValidation("Accept", mediaType); + + // Act + var response = await client.SendAsync(request); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(mediaType, response.Content.Headers.ContentType.MediaType); + Assert.Equal("{\"message\":\"hello\"}", content); + } + + // Using an Accept header can't force Json to not be Json. If your accept header doesn't jive with the + // formatters/content-type configured on the result it will be ignored. + [Theory] + [InlineData("application/xml")] + [InlineData("text/xml")] + public async Task JsonResult_CustomFormatter_Conneg_Fails(string mediaType) + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + var url = "http://localhost/JsonResult/CustomFormatter"; + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.TryAddWithoutValidation("Accept", mediaType); + + // Act + var response = await client.SendAsync(request); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType.MediaType); + Assert.Equal("{\"message\":\"hello\"}", content); + } + + [Fact] + public async Task JsonResult_CustomContentType() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + var url = "http://localhost/JsonResult/CustomContentType"; + + var request = new HttpRequestMessage(HttpMethod.Get, url); + + // Act + var response = await client.SendAsync(request); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/message+json", response.Content.Headers.ContentType.MediaType); + Assert.Equal("{\"Message\":\"hello\"}", content); + } + } +} \ No newline at end of file diff --git a/test/WebSites/BasicWebSite/Controllers/JsonResultController.cs b/test/WebSites/BasicWebSite/Controllers/JsonResultController.cs new file mode 100644 index 0000000000..a88d498fc5 --- /dev/null +++ b/test/WebSites/BasicWebSite/Controllers/JsonResultController.cs @@ -0,0 +1,45 @@ +// 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; +using Microsoft.AspNet.Mvc.HeaderValueAbstractions; +using Newtonsoft.Json.Serialization; + +namespace BasicWebSite.Controllers +{ + public class JsonResultController : Controller + { + public JsonResult Plain() + { + return Json(new { Message = "hello" }); + } + + public JsonResult CustomFormatter() + { + var formatter = new JsonOutputFormatter(); + formatter.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver(); + + return new JsonResult(new { Message = "hello" }, formatter); + } + + public JsonResult CustomContentType() + { + var formatter = new JsonOutputFormatter(); + formatter.SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/message+json")); + + var result = new JsonResult(new { Message = "hello" }, formatter); + result.ContentTypes.Add(MediaTypeHeaderValue.Parse("application/message+json")); + return result; + } + + public JsonResult Null() + { + return Json(null); + } + + public JsonResult String() + { + return Json("hello"); + } + } +} \ No newline at end of file