Fix #1370 - Always use the provided formatter in JsonResult

The change here is to always use the provided formatter, instead of using
it as a fallback. This is much less surprising for users.

There are some other subtle changes here and cleanup of the tests, as well
as documentation additions.

The primary change is that we still want to run 'select' on a formatter
even if it's the only one. This allows us to choose a content type based
on the accept header.

In the case of a user-provided formatter, we'll try to honor the best
possible combination of Accept and specified ContentTypes (specified
ContentTypes win if there's a conflict). If nothing works, we'll still run
the user-provided formatter and let it decide what to do.

In the case of the default (formatters from options) we do conneg, and if
there's a conflict, fall back to a global (from services)
JsonOutputFormatter - we let it decide what to do.

This should leave us with a defined and tested behavior for all cases.
This commit is contained in:
Ryan Nowak 2014-10-29 16:20:24 -07:00
parent d178200795
commit 105c99cbf2
6 changed files with 494 additions and 147 deletions

View File

@ -9,104 +9,124 @@ using Microsoft.Framework.DependencyInjection;
namespace Microsoft.AspNet.Mvc
{
/// <summary>
/// An action result which formats the given object as JSON.
/// </summary>
public class JsonResult : ActionResult
{
private static readonly IList<MediaTypeHeaderValue> _defaultSupportedContentTypes =
new List<MediaTypeHeaderValue>()
{
MediaTypeHeaderValue.Parse("application/json"),
MediaTypeHeaderValue.Parse("text/json"),
};
private IOutputFormatter _defaultFormatter;
/// <summary>
/// The list of content-types used for formatting when <see cref="ContentTypes"/> is null or empty.
/// </summary>
public static readonly IReadOnlyList<MediaTypeHeaderValue> DefaultContentTypes = new MediaTypeHeaderValue[]
{
MediaTypeHeaderValue.Parse("application/json"),
MediaTypeHeaderValue.Parse("text/json"),
};
private ObjectResult _objectResult;
public JsonResult(object data) :
this(data, null)
/// <summary>
/// Creates a new <see cref="JsonResult"/> with the given <paramref name="data"/>.
/// </summary>
/// <param name="value">The value to format as JSON.</param>
public JsonResult(object value)
: this(value, formatter: null)
{
}
/// <summary>
/// Creates an instance of <see cref="JsonResult"/> class.
/// Creates a new <see cref="JsonResult"/> with the given <paramref name="data"/>.
/// </summary>
/// <param name="data"></param>
/// <param name="defaultFormatter">If no matching formatter is found,
/// the response is written to using defaultFormatter.</param>
/// <remarks>
/// The default formatter must be able to handle either application/json
/// or text/json.
/// </remarks>
public JsonResult(object data, IOutputFormatter defaultFormatter)
/// <param name="value">The value to format as JSON.</param>
/// <param name="formatter">The formatter to use, or <c>null</c> to choose a formatter dynamically.</param>
public JsonResult(object value, IOutputFormatter formatter)
{
_defaultFormatter = defaultFormatter;
_objectResult = new ObjectResult(data);
Value = value;
Formatter = formatter;
ContentTypes = new List<MediaTypeHeaderValue>();
}
public object Value
{
get
{
return _objectResult.Value;
}
set
{
_objectResult.Value = value;
}
}
/// <summary>
/// Gets or sets the list of supported Content-Types.
/// </summary>
public IList<MediaTypeHeaderValue> ContentTypes { get; set; }
public IList<MediaTypeHeaderValue> ContentTypes
{
get
{
return _objectResult.ContentTypes;
}
set
{
_objectResult.ContentTypes = value;
}
}
/// <summary>
/// Gets or sets the formatter.
/// </summary>
public IOutputFormatter Formatter { get; set; }
/// <summary>
/// Gets or sets the value to be formatted.
/// </summary>
public object Value { get; set; }
/// <inheritdoc />
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<IOutputFormattersProvider>()
.OutputFormatters;
var formatter = _objectResult.SelectFormatter(formatterContext, defaultFormatters);
if (formatter == null)
if (Formatter == null)
{
formatter = _defaultFormatter ?? formatterContext.ActionContext
.HttpContext
.RequestServices
.GetRequiredService<JsonOutputFormatter>();
}
// If no formatter was provided, then run Conneg with the formatters configured in options.
var formatters = formatterContext
.ActionContext
.HttpContext
.RequestServices
.GetRequiredService<IOutputFormattersProvider>()
.OutputFormatters
.OfType<IJsonOutputFormatter>()
.ToArray();
return formatter;
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<JsonOutputFormatter>();
// 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;
}
}
}
}

View File

@ -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
{
/// <summary>
/// An output formatter that specializes in writing JSON content.
/// </summary>
/// <remarks>
/// The <see cref="JsonResult"/> class filter the collection of
/// <see cref="IOutputFormattersProvider.OutputFormatters"/> and use only those which implement
/// <see cref="IJsonOutputFormatter"/>.
///
/// To create a custom formatter that can be used by <see cref="JsonResult"/>, derive from
/// <see cref="JsonOutputFormatter"/> or implement <see cref="IJsonOutputFormatter"/>.
/// </remarks>
public interface IJsonOutputFormatter : IOutputFormatter
{
}
}

View File

@ -9,7 +9,7 @@ using Newtonsoft.Json;
namespace Microsoft.AspNet.Mvc
{
public class JsonOutputFormatter : OutputFormatter
public class JsonOutputFormatter : OutputFormatter, IJsonOutputFormatter
{
private JsonSerializerSettings _serializerSettings;

View File

@ -9,11 +9,11 @@ using System.Text;
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 @@ namespace Microsoft.AspNet.Mvc
= 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<HttpResponse>();
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<IOutputFormatter>()
{
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()
{
// Arrange
var expected = Enumerable.Concat(Encoding.UTF8.GetPreamble(), _abcdUTF8Bytes);
var memoryStream = new MemoryStream();
var response = new Mock<HttpResponse>();
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);
// Act
await result.ExecuteResultAsync(actionContext);
// Assert
Assert.Equal(expected, memoryStream.ToArray());
}
public async Task ExecuteResult_UsesDefaultFormatter_IfNoneIsRegistered_AndNoneIsPassed()
public async Task ExecuteResultAsync_Null()
{
// Arrange
var expected = _abcdUTF8Bytes;
var memoryStream = new MemoryStream();
var response = new Mock<HttpResponse>();
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<IOutputFormatter>()
{
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);
}
private HttpContext GetHttpContext(HttpResponse response, bool registerDefaultFormatter = true)
[Fact]
public async Task ExecuteResultAsync_OptionsFormatter_Conneg()
{
var defaultFormatters = registerDefaultFormatter ? new List<IOutputFormatter>() { new JsonOutputFormatter() } :
new List<IOutputFormatter>();
var httpContext = new Mock<HttpContext>();
// Arrange
var expected = _abcdUTF8Bytes;
var optionsFormatters = new List<IOutputFormatter>()
{
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, written);
Assert.Equal("text/json;charset=utf-8", context.Response.ContentType);
}
[Fact]
public async Task ExecuteResultAsync_UsesPassedInFormatter()
{
// 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);
}
[Fact]
public async Task ExecuteResultAsync_UsesPassedInFormatter_ContentTypeSpecified()
{
// Arrange
var expected = _abcdUTF8Bytes;
var context = GetHttpContext();
var actionContext = new ActionContext(context, new RouteData(), new ActionDescriptor());
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<IOutputFormatter> optionsFormatters = null,
bool enableFallback = false)
{
var httpContext = new DefaultHttpContext();
httpContext.Response.Body = new MemoryStream();
var services = new Mock<IServiceProvider>(MockBehavior.Strict);
httpContext.RequestServices = services.Object;
var mockFormattersProvider = new Mock<IOutputFormattersProvider>();
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;
mockFormattersProvider
.SetupGet(o => o.OutputFormatters)
.Returns(optionsFormatters ?? new List<IOutputFormatter>());
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)
{
services
.Setup(s => s.GetService(typeof(JsonOutputFormatter)))
.Returns(new JsonOutputFormatter());
}
return httpContext;
}
private class TestJsonFormatter : IOutputFormatter
private byte[] GetWrittenBytes(HttpContext context)
{
public Encoding Encoding { get; set; }
public bool CanWriteResult(OutputFormatterContext context, MediaTypeHeaderValue contentType)
{
return true;
}
public IReadOnlyList<MediaTypeHeaderValue> GetSupportedContentTypes(Type declaredType,
Type runtimeType,
MediaTypeHeaderValue contentType)
{
return null;
}
public async Task WriteAsync(OutputFormatterContext context)
{
// Override using the selected encoding.
context.SelectedEncoding = Encoding;
var jsonFormatter = new JsonOutputFormatter();
await jsonFormatter.WriteResponseBodyAsync(context);
}
context.Response.Body.Seek(0, SeekOrigin.Begin);
return Assert.IsType<MemoryStream>(context.Response.Body).ToArray();
}
}
}

View File

@ -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<IApplicationBuilder> _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);
}
}
}

View File

@ -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");
}
}
}