File: Infrastructure\ObjectResultExecutorTest.cs
Web Access
Project: src\src\Mvc\Mvc.Core\test\Microsoft.AspNetCore.Mvc.Core.Test.csproj (Microsoft.AspNetCore.Mvc.Core.Test)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
 
namespace Microsoft.AspNetCore.Mvc.Infrastructure;
 
public class ObjectResultExecutorTest
{
    [Fact]
    public async Task ExecuteAsync_UsesSpecifiedContentType()
    {
        // Arrange
        var executor = CreateExecutor();
 
        var httpContext = new DefaultHttpContext();
        var actionContext = new ActionContext() { HttpContext = httpContext };
        httpContext.Request.Headers.Accept = "application/xml"; // This will not be used
        httpContext.Response.ContentType = "text/json";
 
        var result = new ObjectResult("input")
        {
            ContentTypes = { "text/xml", },
        };
        result.Formatters.Add(new TestXmlOutputFormatter());
        result.Formatters.Add(new TestJsonOutputFormatter());
        result.Formatters.Add(new TestStringOutputFormatter()); // This will be chosen based on the content type
 
        // Act
        await executor.ExecuteAsync(actionContext, result);
 
        // Assert
        MediaTypeAssert.Equal("text/xml; charset=utf-8", httpContext.Response.ContentType);
    }
 
    // For this test case probably the most common use case is when there is a format mapping based
    // content type selected but the developer had set the content type on the Response.ContentType
    [Fact]
    public async Task ExecuteAsync_ContentTypeProvidedFromResponseAndObjectResult_UsesResponseContentType()
    {
        // Arrange
        var executor = CreateExecutor();
 
        var httpContext = new DefaultHttpContext();
        var actionContext = new ActionContext() { HttpContext = httpContext };
        httpContext.Request.Headers.Accept = "application/xml"; // This will not be used
        httpContext.Response.ContentType = "text/plain";
 
        var result = new ObjectResult("input");
        result.Formatters.Add(new TestXmlOutputFormatter());
        result.Formatters.Add(new TestJsonOutputFormatter());
        result.Formatters.Add(new TestStringOutputFormatter()); // This will be chosen based on the content type
 
        // Act
        await executor.ExecuteAsync(actionContext, result);
 
        // Assert
        MediaTypeAssert.Equal("text/plain; charset=utf-8", httpContext.Response.ContentType);
    }
 
    [Fact]
    public async Task ExecuteAsync_WithOneProvidedContentType_FromResponseContentType_IgnoresAcceptHeader()
    {
        // Arrange
        var executor = CreateExecutor();
 
        var httpContext = new DefaultHttpContext();
        var actionContext = new ActionContext() { HttpContext = httpContext };
        httpContext.Request.Headers.Accept = "application/xml"; // This will not be used
        httpContext.Response.ContentType = "application/json";
 
        var result = new ObjectResult("input");
        result.Formatters.Add(new TestXmlOutputFormatter());
        result.Formatters.Add(new TestJsonOutputFormatter()); // This will be chosen based on the content type
 
        // Act
        await executor.ExecuteAsync(actionContext, result);
 
        // Assert
        Assert.Equal("application/json; charset=utf-8", httpContext.Response.ContentType);
    }
 
    [Fact]
    public async Task ExecuteAsync_WithOneProvidedContentType_FromResponseContentType_NoFallback()
    {
        // Arrange
        var executor = CreateExecutor();
 
        var httpContext = GetHttpContext();
        var actionContext = new ActionContext() { HttpContext = httpContext };
        httpContext.Request.Headers.Accept = "application/xml"; // This will not be used
        httpContext.Response.ContentType = "application/json";
 
        var result = new ObjectResult("input");
        result.Formatters.Add(new TestXmlOutputFormatter());
 
        // Act
        await executor.ExecuteAsync(actionContext, result);
 
        // Assert
        Assert.Equal(406, httpContext.Response.StatusCode);
    }
 
    [Fact]
    public async Task ExecuteAsync_WithResponseAndObjectResultContentType_ForProblemDetailsValue_UsesXMLContentType()
    {
        // Arrange
        var executor = CreateExecutor();
 
        var httpContext = new DefaultHttpContext();
        var actionContext = new ActionContext() { HttpContext = httpContext };
        httpContext.Response.ContentType = "application/xml"; // This will not be used
 
        var result = new ObjectResult(new ProblemDetails())
        {
            ContentTypes = { "text/plain" }, // This will not be used
        };
        result.Formatters.Add(new TestXmlOutputFormatter());  // This will be chosen based on the problem details content type
        result.Formatters.Add(new TestJsonOutputFormatter());
        result.Formatters.Add(new TestStringOutputFormatter());
 
        // Act
        await executor.ExecuteAsync(actionContext, result);
 
        // Assert
        MediaTypeAssert.Equal("application/problem+xml; charset=utf-8", httpContext.Response.ContentType);
    }
 
    [Fact]
    public async Task ExecuteAsync_WithResponseContentType_ForProblemDetailsValue_UsesProblemDetailXMLContentType()
    {
        // Arrange
        var executor = CreateExecutor();
 
        var httpContext = new DefaultHttpContext();
        var actionContext = new ActionContext() { HttpContext = httpContext };
        httpContext.Response.ContentType = "application/json"; // This will not be used
 
        var result = new ObjectResult(new ProblemDetails());
        result.Formatters.Add(new TestXmlOutputFormatter()); // This will be chosen based on the problem details content type
        result.Formatters.Add(new TestJsonOutputFormatter());
        result.Formatters.Add(new TestStringOutputFormatter());
 
        // Act
        await executor.ExecuteAsync(actionContext, result);
 
        // Assert
        MediaTypeAssert.Equal("application/problem+xml; charset=utf-8", httpContext.Response.ContentType);
    }
 
    [Fact]
    public async Task ExecuteAsync_ForProblemDetailsValue_UsesProblemDetailsContentType()
    {
        // Arrange
        var executor = CreateExecutor();
 
        var httpContext = new DefaultHttpContext();
        var actionContext = new ActionContext() { HttpContext = httpContext };
        httpContext.Response.ContentType = "application/json"; // This will not be used
 
        var result = new ObjectResult(new ProblemDetails());
        result.Formatters.Add(new TestXmlOutputFormatter()); // This will be chosen based on the problem details content type
        result.Formatters.Add(new TestJsonOutputFormatter());
        result.Formatters.Add(new TestStringOutputFormatter());
 
        // Act
        await executor.ExecuteAsync(actionContext, result);
 
        // Assert
        MediaTypeAssert.Equal("application/problem+xml; charset=utf-8", httpContext.Response.ContentType);
    }
 
    [Fact]
    public async Task ExecuteAsync_ForProblemDetailsValue_UsesProblemDetailsJsonContentType_BasedOnAcceptHeader()
    {
        // Arrange
        var executor = CreateExecutor();
 
        var httpContext = new DefaultHttpContext();
        var actionContext = new ActionContext() { HttpContext = httpContext };
        httpContext.Request.Headers.Accept = "application/json"; // This will not be used
        httpContext.Response.ContentType = "application/xml"; // This will not be used
 
        var result = new ObjectResult(new ProblemDetails())
        {
            ContentTypes = { "text/plain" }, // This will not be used
        };
        result.Formatters.Add(new TestJsonOutputFormatter()); // This will be chosen based on the Accept Headers "application/json"
        result.Formatters.Add(new TestXmlOutputFormatter());
        result.Formatters.Add(new TestStringOutputFormatter());
 
        // Act
        await executor.ExecuteAsync(actionContext, result);
 
        // Assert
        MediaTypeAssert.Equal("application/problem+json; charset=utf-8", httpContext.Response.ContentType);
    }
 
    [Fact]
    public async Task ExecuteAsync_ForProblemDetailsValue_UsesProblemDetailsXMLContentType_BasedOnAcceptHeader()
    {
        // Arrange
        var executor = CreateExecutor();
 
        var httpContext = new DefaultHttpContext();
        var actionContext = new ActionContext() { HttpContext = httpContext };
        httpContext.Request.Headers.Accept = "application/xml"; // This will not be used
 
        var result = new ObjectResult(new ProblemDetails())
        {
            ContentTypes = { "text/plain" }, // This will not be used
        };
        result.Formatters.Add(new TestJsonOutputFormatter());
        result.Formatters.Add(new TestXmlOutputFormatter()); // This will be chosen based on the Accept Headers "application/xml"
        result.Formatters.Add(new TestStringOutputFormatter());
 
        // Act
        await executor.ExecuteAsync(actionContext, result);
 
        // Assert
        MediaTypeAssert.Equal("application/problem+xml; charset=utf-8", httpContext.Response.ContentType);
    }
 
    [Fact]
    public async Task ExecuteAsync_NoContentTypeProvidedForProblemDetails_UsesDefaultContentTypes()
    {
        // Arrange
        var executor = CreateExecutor();
 
        var httpContext = new DefaultHttpContext();
        var actionContext = new ActionContext() { HttpContext = httpContext };
 
        var result = new ObjectResult(new ProblemDetails());
        result.Formatters.Add(new TestXmlOutputFormatter());  // This will be chosen based on the problem details added content type
        result.Formatters.Add(new TestJsonOutputFormatter());
        result.Formatters.Add(new TestStringOutputFormatter());
 
        // Act
        await executor.ExecuteAsync(actionContext, result);
 
        // Assert
        MediaTypeAssert.Equal("application/problem+xml; charset=utf-8", httpContext.Response.ContentType);
    }
 
    [Fact]
    public async Task ExecuteAsync_NoFormatterFound_Returns406()
    {
        // Arrange
        var executor = CreateExecutor();
 
        var actionContext = new ActionContext()
        {
            HttpContext = GetHttpContext(),
        };
 
        var result = new ObjectResult("input");
 
        // This formatter won't write anything
        result.Formatters = new FormatterCollection<IOutputFormatter>
            {
                new CannotWriteFormatter(),
            };
 
        // Act
        await executor.ExecuteAsync(actionContext, result);
 
        // Assert
        Assert.Equal(StatusCodes.Status406NotAcceptable, actionContext.HttpContext.Response.StatusCode);
    }
 
    [Fact]
    public async Task ExecuteAsync_FallsBackOnFormattersInOptions()
    {
        // Arrange
        var options = new MvcOptions();
        options.OutputFormatters.Add(new TestJsonOutputFormatter());
 
        var executor = CreateExecutor(options: options);
 
        var actionContext = new ActionContext()
        {
            HttpContext = GetHttpContext(),
        };
 
        var result = new ObjectResult("someValue");
 
        // Act
        await executor.ExecuteAsync(actionContext, result);
 
        // Assert
        Assert.Equal(
            "application/json; charset=utf-8",
            actionContext.HttpContext.Response.Headers.ContentType);
    }
 
    [Fact]
    public async Task ExecuteAsync_ThrowsWithNoFormatters()
    {
        // Arrange
        var expected = $"'{typeof(MvcOptions).FullName}.{nameof(MvcOptions.OutputFormatters)}' must not be " +
            $"empty. At least one '{typeof(IOutputFormatter).FullName}' is required to format a response.";
        var executor = CreateExecutor();
        var actionContext = new ActionContext
        {
            HttpContext = GetHttpContext(),
        };
        var result = new ObjectResult("some value");
 
        // Act & Assert
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => executor.ExecuteAsync(actionContext, result));
        Assert.Equal(expected, exception.Message);
    }
 
    [Theory]
    [InlineData(new[] { "application/*" }, "application/*")]
    [InlineData(new[] { "application/xml", "application/*", "application/json" }, "application/*")]
    [InlineData(new[] { "application/*", "application/json" }, "application/*")]
    [InlineData(new[] { "*/*" }, "*/*")]
    [InlineData(new[] { "application/xml", "*/*", "application/json" }, "*/*")]
    [InlineData(new[] { "*/*", "application/json" }, "*/*")]
    [InlineData(new[] { "application/json", "application/*+json" }, "application/*+json")]
    [InlineData(new[] { "application/entiy+json;*", "application/json" }, "application/entiy+json;*")]
    public async Task ExecuteAsync_MatchAllContentType_Throws(string[] contentTypes, string invalidContentType)
    {
        // Arrange
        var result = new ObjectResult("input");
 
        var mediaTypes = new MediaTypeCollection();
        foreach (var contentType in contentTypes)
        {
            mediaTypes.Add(contentType);
        }
 
        result.ContentTypes = mediaTypes;
 
        var executor = CreateExecutor();
 
        var actionContext = new ActionContext() { HttpContext = new DefaultHttpContext() };
 
        // Act & Assert
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => executor.ExecuteAsync(actionContext, result));
 
        var expectedMessage = string.Format(
            CultureInfo.CurrentCulture,
            "The content-type '{0}' added in the 'ContentTypes' property is " +
            "invalid. Media types which match all types or match all subtypes are not supported.",
          invalidContentType);
        Assert.Equal(expectedMessage, exception.Message);
    }
 
    [Theory]
    // Chrome & Opera
    [InlineData("text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "application/json; charset=utf-8")]
    // IE
    [InlineData("text/html,application/xhtml+xml,*/*", "application/json; charset=utf-8")]
    // Firefox & Safari
    [InlineData("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "application/json; charset=utf-8")]
    // Misc
    [InlineData("*/*", @"application/json; charset=utf-8")]
    [InlineData("text/html,*/*;q=0.8,application/xml;q=0.9", "application/json; charset=utf-8")]
    public async Task ExecuteAsync_SelectDefaultFormatter_OnAllMediaRangeAcceptHeaderMediaType(
        string acceptHeader,
        string expectedContentType)
    {
        // Arrange
        var options = new MvcOptions();
        options.RespectBrowserAcceptHeader = false;
 
        var executor = CreateExecutor(options: options);
 
        var result = new ObjectResult("input");
        result.Formatters.Add(new TestJsonOutputFormatter());
        result.Formatters.Add(new TestXmlOutputFormatter());
 
        var actionContext = new ActionContext()
        {
            HttpContext = GetHttpContext(),
        };
        actionContext.HttpContext.Request.Headers.Accept = acceptHeader;
 
        // Act
        await executor.ExecuteAsync(actionContext, result);
 
        // Assert
        Assert.Equal(expectedContentType, actionContext.HttpContext.Response.Headers.ContentType);
    }
 
    [Theory]
    // Chrome & Opera
    [InlineData("text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "application/xml; charset=utf-8")]
    // IE
    [InlineData("text/html,application/xhtml+xml,*/*", "application/json; charset=utf-8")]
    // Firefox & Safari
    [InlineData("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "application/xml; charset=utf-8")]
    // Misc
    [InlineData("*/*", @"application/json; charset=utf-8")]
    [InlineData("text/html,*/*;q=0.8,application/xml;q=0.9", "application/xml; charset=utf-8")]
    public async Task ObjectResult_PerformsContentNegotiation_OnAllMediaRangeAcceptHeaderMediaType(
        string acceptHeader,
        string expectedContentType)
    {
        // Arrange
        var options = new MvcOptions();
        options.RespectBrowserAcceptHeader = true;
 
        var executor = CreateExecutor(options: options);
 
        var result = new ObjectResult("input");
        result.Formatters.Add(new TestJsonOutputFormatter());
        result.Formatters.Add(new TestXmlOutputFormatter());
 
        var actionContext = new ActionContext()
        {
            HttpContext = GetHttpContext(),
        };
        actionContext.HttpContext.Request.Headers.Accept = acceptHeader;
 
        // Act
        await executor.ExecuteAsync(actionContext, result);
 
        // Assert
        var responseContentType = actionContext.HttpContext.Response.Headers.ContentType;
        MediaTypeAssert.Equal(expectedContentType, responseContentType);
    }
 
    [Fact]
    public async Task ObjectResult_NullValue()
    {
        // Arrange
        var executor = CreateExecutor();
        var result = new ObjectResult(value: null);
        var formatter = new TestJsonOutputFormatter();
        result.Formatters.Add(formatter);
 
        var actionContext = new ActionContext()
        {
            HttpContext = GetHttpContext(),
        };
 
        // Act
        await executor.ExecuteAsync(actionContext, result);
 
        // Assert
        var formatterContext = formatter.LastOutputFormatterContext;
        Assert.Null(formatterContext.Object);
    }
 
    private static IServiceCollection CreateServices()
    {
        var services = new ServiceCollection();
 
        services.AddSingleton<ILoggerFactory>(NullLoggerFactory.Instance);
 
        return services;
    }
 
    private static HttpContext GetHttpContext()
    {
        var services = CreateServices();
 
        var httpContext = new DefaultHttpContext();
        httpContext.RequestServices = services.BuildServiceProvider();
 
        return httpContext;
    }
 
    private static ObjectResultExecutor CreateExecutor(MvcOptions options = null)
    {
        options ??= new MvcOptions();
        var optionsAccessor = Options.Create(options);
        var selector = new DefaultOutputFormatterSelector(optionsAccessor, NullLoggerFactory.Instance);
        return new ObjectResultExecutor(selector, new TestHttpResponseStreamWriterFactory(), NullLoggerFactory.Instance, optionsAccessor);
    }
 
    private class CannotWriteFormatter : IOutputFormatter
    {
        public virtual bool CanWriteResult(OutputFormatterCanWriteContext context)
        {
            return false;
        }
 
        public virtual Task WriteAsync(OutputFormatterWriteContext context)
        {
            throw new NotImplementedException();
        }
    }
 
    private class TestJsonOutputFormatter : TextOutputFormatter
    {
        public TestJsonOutputFormatter()
        {
            SupportedMediaTypes.Add(new MediaTypeHeaderValue("application/json"));
            SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/json"));
            SupportedMediaTypes.Add(new MediaTypeHeaderValue("application/*+json"));
 
            SupportedEncodings.Add(Encoding.UTF8);
        }
 
        public OutputFormatterWriteContext LastOutputFormatterContext { get; private set; }
 
        public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
        {
            LastOutputFormatterContext = context;
            return Task.FromResult(0);
        }
    }
 
    private class TestXmlOutputFormatter : TextOutputFormatter
    {
        public TestXmlOutputFormatter()
        {
            SupportedMediaTypes.Add(new MediaTypeHeaderValue("application/xml"));
            SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/xml"));
            SupportedMediaTypes.Add(new MediaTypeHeaderValue("application/*+xml"));
 
            SupportedEncodings.Add(Encoding.UTF8);
        }
 
        public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
        {
            return Task.FromResult(0);
        }
    }
 
    private class TestStringOutputFormatter : TextOutputFormatter
    {
        public TestStringOutputFormatter()
        {
            SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/plain"));
 
            SupportedEncodings.Add(Encoding.UTF8);
        }
 
        public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
        {
            return Task.FromResult(0);
        }
    }
 
    private class ServerContentTypeOnlyFormatter : OutputFormatter
    {
        public override bool CanWriteResult(OutputFormatterCanWriteContext context)
        {
            // This test formatter matches if and only if the content type is specified
            // as "server defined". This lets tests identify what value the ObjectResultExecutor
            // passed for that flag.
            return context.ContentTypeIsServerDefined;
        }
 
        public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context)
        {
            return Task.FromResult(0);
        }
    }
}