diff --git a/src/Microsoft.AspNet.Mvc.Core/ActionResults/ObjectResult.cs b/src/Microsoft.AspNet.Mvc.Core/ActionResults/ObjectResult.cs index 3eb0cf431f..9884e3c5c6 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ActionResults/ObjectResult.cs +++ b/src/Microsoft.AspNet.Mvc.Core/ActionResults/ObjectResult.cs @@ -69,6 +69,14 @@ public override async Task ExecuteResultAsync(ActionContext context) public virtual IOutputFormatter SelectFormatter(OutputFormatterContext formatterContext, IEnumerable formatters) { + if (ContentTypes.Count == 1) + { + // There is only one content type specified so we can skip looking at the accept headers. + return SelectFormatterUsingAnyAcceptableContentType(formatterContext, + formatters, + ContentTypes); + } + var incomingAcceptHeaderMediaTypes = formatterContext.ActionContext.HttpContext.Request.GetTypedHeaders().Accept ?? new MediaTypeHeaderValue[] { }; @@ -143,14 +151,6 @@ public override async Task ExecuteResultAsync(ActionContext context) } } } - else if (ContentTypes.Count == 1) - { - // There is only one value that can be supported. - selectedFormatter = SelectFormatterUsingAnyAcceptableContentType( - formatterContext, - formatters, - ContentTypes); - } else { if (respectAcceptHeader) diff --git a/src/Microsoft.AspNet.Mvc.Core/Formatters/StreamOutputFormatter.cs b/src/Microsoft.AspNet.Mvc.Core/Formatters/StreamOutputFormatter.cs new file mode 100644 index 0000000000..dafc24fc94 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Formatters/StreamOutputFormatter.cs @@ -0,0 +1,76 @@ +// 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.IO; +using System.Threading.Tasks; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNet.Mvc +{ + /// + /// Always copies the stream to the response, regardless of requested content type. + /// + public class StreamOutputFormatter : IOutputFormatter + { + + /// + /// Echos the if the implements + /// and is not null. + /// + /// The declared type for which the supported content types are desired. + /// The runtime type for which the supported content types are desired. + /// + /// The content type for which the supported content types are desired, or null if any content + /// type can be used. + /// + /// Content types which are supported by this formatter. + public IReadOnlyList GetSupportedContentTypes( + Type declaredType, + Type runtimeType, + MediaTypeHeaderValue contentType) + { + if (contentType == null) + { + return null; + } + + if (runtimeType != null && typeof(Stream).IsAssignableFrom(runtimeType)) + { + return new[] { contentType }; + } + + return null; + } + + /// + public bool CanWriteResult([NotNull] OutputFormatterContext context, MediaTypeHeaderValue contentType) + { + // Ignore the passed in content type, if the object is a Stream. + // always return it as a text/plain format. + if (context.Object is Stream) + { + context.SelectedContentType = contentType; + return true; + } + + return false; + } + + /// + public async Task WriteAsync([NotNull] OutputFormatterContext context) + { + var valueAsStream = ((Stream)context.Object); + + var response = context.ActionContext.HttpContext.Response; + + if (context.SelectedContentType != null) + { + response.ContentType = context.SelectedContentType.ToString(); + } + + await valueAsStream.CopyToAsync(response.Body); + } + } +} diff --git a/src/Microsoft.AspNet.Mvc/MvcOptionsSetup.cs b/src/Microsoft.AspNet.Mvc/MvcOptionsSetup.cs index 85d97c6d03..f04ac11847 100644 --- a/src/Microsoft.AspNet.Mvc/MvcOptionsSetup.cs +++ b/src/Microsoft.AspNet.Mvc/MvcOptionsSetup.cs @@ -44,6 +44,7 @@ public static void ConfigureMvc(MvcOptions options) // Set up default output formatters. options.OutputFormatters.Add(new HttpNoContentOutputFormatter()); options.OutputFormatters.Add(new StringOutputFormatter()); + options.OutputFormatters.Add(new StreamOutputFormatter()); options.OutputFormatters.Add(new JsonOutputFormatter()); // Set up default mapping for json extensions to content type diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/Formatters/StreamOutputFormatterTest.cs b/test/Microsoft.AspNet.Mvc.Core.Test/Formatters/StreamOutputFormatterTest.cs new file mode 100644 index 0000000000..26969d4a1c --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Core.Test/Formatters/StreamOutputFormatterTest.cs @@ -0,0 +1,88 @@ +// 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.IO; +using Microsoft.AspNet.Mvc; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Microsoft.AspNet.Mvc +{ + public class StreamOutputFormatterTest + { + [InlineData(typeof(Stream), typeof(FileStream), "text/plain", "text/plain")] + [InlineData(typeof(object), typeof(FileStream), "text/plain", "text/plain")] + [InlineData(typeof(object), typeof(MemoryStream), "text/plain", "text/plain")] + [InlineData(typeof(object), typeof(object), "text/plain", null)] + [InlineData(typeof(object), typeof(string), "text/plain", null)] + [InlineData(typeof(object), null, "text/plain", null)] + [InlineData(typeof(IActionResult), null, "text/plain", null)] + [InlineData(typeof(IActionResult), typeof(IActionResult), "text/plain", null)] + [Theory] + public void GetSupportedContentTypes_ReturnsAppropriateValues(Type declaredType, Type runtimeType, + string contentType, string expected) + { + // Arrange + var formatter = new StreamOutputFormatter(); + var contentTypeHeader = contentType == null ? null : new MediaTypeHeaderValue(contentType); + + // Act + var contentTypes = formatter.GetSupportedContentTypes(declaredType, runtimeType, contentTypeHeader); + + // Assert + if (expected == null) + { + Assert.Null(contentTypes); + } + else + { + Assert.Equal(1, contentTypes.Count); + Assert.Equal(expected, contentTypes[0].ToString()); + } + } + + [InlineData(typeof(object))] + [InlineData(typeof(SimplePOCO))] + [InlineData(null)] + [Theory] + public void CanWriteResult_OnlyActsOnStreams(Type type) + { + // Arrange + var formatter = new StreamOutputFormatter(); + var context = new OutputFormatterContext(); + var contentType = new MediaTypeHeaderValue("text/plain"); + + context.Object = type != null ? Activator.CreateInstance(type) : null; + + // Act + var result = formatter.CanWriteResult(context, contentType); + + // Assert + Assert.False(result); + Assert.Null(context.SelectedContentType); + } + + [Fact] + public void CanWriteResult_SetsContentType() + { + // Arrange + var formatter = new StreamOutputFormatter(); + var contentType = new MediaTypeHeaderValue("text/plain"); + var context = new OutputFormatterContext(); + context.Object = new MemoryStream(); + + // Act + var result = formatter.CanWriteResult(context, contentType); + + // Assert + Assert.True(result); + Assert.Same(contentType, context.SelectedContentType); + } + + private class SimplePOCO + { + public int Id { get; set; } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/StreamOutputFormatterTest.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/StreamOutputFormatterTest.cs new file mode 100644 index 0000000000..0a1a02d53f --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/StreamOutputFormatterTest.cs @@ -0,0 +1,42 @@ +// 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.Headers; +using System.Threading.Tasks; +using FormatterWebSite; +using Microsoft.AspNet.Builder; +using Microsoft.AspNet.TestHost; +using Xunit; + +namespace Microsoft.AspNet.Mvc.FunctionalTests +{ + public class StreamOutputFormatterTest + { + private readonly IServiceProvider _provider = TestHelper.CreateServices(nameof(FormatterWebSite)); + private readonly Action _app = new Startup().Configure; + + [InlineData("SimpleMemoryStream", null)] + [InlineData("MemoryStreamWithContentType", "text/html")] + [InlineData("MemoryStreamWithContentTypeFromProduces", "text/plain")] + [InlineData("MemoryStreamWithContentTypeFromProducesWithMultipleValues", "text/html")] + [InlineData("MemoryStreamOverridesContentTypeWithProduces", "text/plain")] + [Theory] + public async Task StreamOutputFormatter_ReturnsAppropriateContentAndContentType(string actionName, string contentType) + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/Stream/" + actionName); + var body = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(contentType, response.Content.Headers.ContentType?.ToString()); + + Assert.Equal("Sample text from a stream", body); + } + } +} diff --git a/test/Microsoft.AspNet.Mvc.Test/MvcOptionSetupTest.cs b/test/Microsoft.AspNet.Mvc.Test/MvcOptionSetupTest.cs index 7e22edeb35..f7f5db364d 100644 --- a/test/Microsoft.AspNet.Mvc.Test/MvcOptionSetupTest.cs +++ b/test/Microsoft.AspNet.Mvc.Test/MvcOptionSetupTest.cs @@ -83,9 +83,10 @@ public void Setup_SetsUpOutputFormatters() setup.Configure(mvcOptions); // Assert - Assert.Equal(3, mvcOptions.OutputFormatters.Count); + Assert.Equal(4, mvcOptions.OutputFormatters.Count); Assert.IsType(mvcOptions.OutputFormatters[0].Instance); Assert.IsType(mvcOptions.OutputFormatters[1].Instance); + Assert.IsType(mvcOptions.OutputFormatters[1].Instance); Assert.IsType(mvcOptions.OutputFormatters[2].Instance); } diff --git a/test/WebSites/FormatterWebSite/Controllers/StreamController.cs b/test/WebSites/FormatterWebSite/Controllers/StreamController.cs new file mode 100644 index 0000000000..f8841b4ec0 --- /dev/null +++ b/test/WebSites/FormatterWebSite/Controllers/StreamController.cs @@ -0,0 +1,61 @@ +// 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.IO; +using Microsoft.AspNet.Mvc; +using Microsoft.Net.Http.Headers; + +namespace FormatterWebSite +{ + public class StreamController : Controller + { + [HttpGet] + public Stream SimpleMemoryStream() + { + return CreateDefaultStream(); + } + + [HttpGet] + public Stream MemoryStreamWithContentType() + { + Response.ContentType = "text/html"; + return CreateDefaultStream(); + } + + [HttpGet] + [Produces("text/plain")] + public Stream MemoryStreamWithContentTypeFromProduces() + { + return CreateDefaultStream(); + } + + [HttpGet] + [Produces("text/html", "text/plain")] + public Stream MemoryStreamWithContentTypeFromProducesWithMultipleValues() + { + return CreateDefaultStream(); + } + + [HttpGet] + [Produces("text/plain")] + public Stream MemoryStreamOverridesContentTypeWithProduces() + { + // Produces will set a ContentType on the implicit ObjecResult and + // ContentType on response are overriden by content types from ObjectResult. + Response.ContentType = "text/html"; + + return CreateDefaultStream(); + } + + private static Stream CreateDefaultStream() + { + var stream = new MemoryStream(); + var writer = new StreamWriter(stream); + writer.Write("Sample text from a stream"); + writer.Flush(); + stream.Seek(0, SeekOrigin.Begin); + + return stream; + } + } +}