diff --git a/AspNetCore.sln b/AspNetCore.sln index f1e9736e420b..9b085a67eedc 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1626,6 +1626,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Compon EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SimpleWebSiteWithWebApplicationBuilderException", "src\Mvc\test\WebSites\SimpleWebSiteWithWebApplicationBuilderException\SimpleWebSiteWithWebApplicationBuilderException.csproj", "{5C641396-7E92-4F5C-A5A1-B4CDF480539B}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Http.Results", "Http.Results", "{323C3EB6-1D15-4B3D-918D-699D7F64DED9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Http.Results", "src\Http\Http.Results\src\Microsoft.AspNetCore.Http.Results.csproj", "{092EA9F6-84D4-41EF-A618-BDA50A0E10A8}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Http.Results.Tests", "src\Http\Http.Results\test\Microsoft.AspNetCore.Http.Results.Tests.csproj", "{F599EAA6-399F-4A91-9B1F-D311305B43D9}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logging.W3C.Sample", "src\Middleware\HttpLogging\samples\Logging.W3C.Sample\Logging.W3C.Sample.csproj", "{17459B97-1AA3-4154-83D3-C6BDC9FA3F85}" EndProject Global @@ -7747,6 +7753,30 @@ Global {5C641396-7E92-4F5C-A5A1-B4CDF480539B}.Release|x64.Build.0 = Release|Any CPU {5C641396-7E92-4F5C-A5A1-B4CDF480539B}.Release|x86.ActiveCfg = Release|Any CPU {5C641396-7E92-4F5C-A5A1-B4CDF480539B}.Release|x86.Build.0 = Release|Any CPU + {092EA9F6-84D4-41EF-A618-BDA50A0E10A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {092EA9F6-84D4-41EF-A618-BDA50A0E10A8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {092EA9F6-84D4-41EF-A618-BDA50A0E10A8}.Debug|x64.ActiveCfg = Debug|Any CPU + {092EA9F6-84D4-41EF-A618-BDA50A0E10A8}.Debug|x64.Build.0 = Debug|Any CPU + {092EA9F6-84D4-41EF-A618-BDA50A0E10A8}.Debug|x86.ActiveCfg = Debug|Any CPU + {092EA9F6-84D4-41EF-A618-BDA50A0E10A8}.Debug|x86.Build.0 = Debug|Any CPU + {092EA9F6-84D4-41EF-A618-BDA50A0E10A8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {092EA9F6-84D4-41EF-A618-BDA50A0E10A8}.Release|Any CPU.Build.0 = Release|Any CPU + {092EA9F6-84D4-41EF-A618-BDA50A0E10A8}.Release|x64.ActiveCfg = Release|Any CPU + {092EA9F6-84D4-41EF-A618-BDA50A0E10A8}.Release|x64.Build.0 = Release|Any CPU + {092EA9F6-84D4-41EF-A618-BDA50A0E10A8}.Release|x86.ActiveCfg = Release|Any CPU + {092EA9F6-84D4-41EF-A618-BDA50A0E10A8}.Release|x86.Build.0 = Release|Any CPU + {F599EAA6-399F-4A91-9B1F-D311305B43D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F599EAA6-399F-4A91-9B1F-D311305B43D9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F599EAA6-399F-4A91-9B1F-D311305B43D9}.Debug|x64.ActiveCfg = Debug|Any CPU + {F599EAA6-399F-4A91-9B1F-D311305B43D9}.Debug|x64.Build.0 = Debug|Any CPU + {F599EAA6-399F-4A91-9B1F-D311305B43D9}.Debug|x86.ActiveCfg = Debug|Any CPU + {F599EAA6-399F-4A91-9B1F-D311305B43D9}.Debug|x86.Build.0 = Debug|Any CPU + {F599EAA6-399F-4A91-9B1F-D311305B43D9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F599EAA6-399F-4A91-9B1F-D311305B43D9}.Release|Any CPU.Build.0 = Release|Any CPU + {F599EAA6-399F-4A91-9B1F-D311305B43D9}.Release|x64.ActiveCfg = Release|Any CPU + {F599EAA6-399F-4A91-9B1F-D311305B43D9}.Release|x64.Build.0 = Release|Any CPU + {F599EAA6-399F-4A91-9B1F-D311305B43D9}.Release|x86.ActiveCfg = Release|Any CPU + {F599EAA6-399F-4A91-9B1F-D311305B43D9}.Release|x86.Build.0 = Release|Any CPU {17459B97-1AA3-4154-83D3-C6BDC9FA3F85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {17459B97-1AA3-4154-83D3-C6BDC9FA3F85}.Debug|Any CPU.Build.0 = Debug|Any CPU {17459B97-1AA3-4154-83D3-C6BDC9FA3F85}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -8564,6 +8594,9 @@ Global {558C46DE-DE16-41D5-8DB7-D6D748E32977} = {3EC71A0E-6515-4A5A-B759-F0BCF1BCFC56} {B1AA24A4-5E02-4DC1-B57F-6EB03F91E4DD} = {44963D50-8B58-44E6-918D-788BCB406695} {5C641396-7E92-4F5C-A5A1-B4CDF480539B} = {088C37A5-30D2-40FB-B031-D163CFBED006} + {323C3EB6-1D15-4B3D-918D-699D7F64DED9} = {627BE8B3-59E6-4F1D-8C9C-76B804D41724} + {092EA9F6-84D4-41EF-A618-BDA50A0E10A8} = {323C3EB6-1D15-4B3D-918D-699D7F64DED9} + {F599EAA6-399F-4A91-9B1F-D311305B43D9} = {323C3EB6-1D15-4B3D-918D-699D7F64DED9} {17459B97-1AA3-4154-83D3-C6BDC9FA3F85} = {022B4B80-E813-4256-8034-11A68146F4EF} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props index fd90ae09e737..1035737d2d83 100644 --- a/eng/ProjectReferences.props +++ b/eng/ProjectReferences.props @@ -29,6 +29,7 @@ + diff --git a/eng/SharedFramework.Local.props b/eng/SharedFramework.Local.props index f230f2e82817..34a5b503e568 100644 --- a/eng/SharedFramework.Local.props +++ b/eng/SharedFramework.Local.props @@ -48,6 +48,7 @@ + diff --git a/src/Framework/test/TestData.cs b/src/Framework/test/TestData.cs index dd11b6d25171..ea48ec61ad4b 100644 --- a/src/Framework/test/TestData.cs +++ b/src/Framework/test/TestData.cs @@ -55,6 +55,7 @@ static TestData() "Microsoft.AspNetCore.Http.Connections.Common", "Microsoft.AspNetCore.Http.Extensions", "Microsoft.AspNetCore.Http.Features", + "Microsoft.AspNetCore.Http.Results", "Microsoft.AspNetCore.HttpLogging", "Microsoft.AspNetCore.HttpOverrides", "Microsoft.AspNetCore.HttpsPolicy", @@ -188,6 +189,7 @@ static TestData() { "Microsoft.AspNetCore.Http.Connections.Common", "6.0.0.0" }, { "Microsoft.AspNetCore.Http.Extensions", "6.0.0.0" }, { "Microsoft.AspNetCore.Http.Features", "6.0.0.0" }, + { "Microsoft.AspNetCore.Http.Results", "6.0.0.0" }, { "Microsoft.AspNetCore.HttpLogging", "6.0.0.0" }, { "Microsoft.AspNetCore.HttpOverrides", "6.0.0.0" }, { "Microsoft.AspNetCore.HttpsPolicy", "6.0.0.0" }, diff --git a/src/Http/Http.Results/src/AcceptedAtRouteResult.cs b/src/Http/Http.Results/src/AcceptedAtRouteResult.cs new file mode 100644 index 000000000000..8159803bccce --- /dev/null +++ b/src/Http/Http.Results/src/AcceptedAtRouteResult.cs @@ -0,0 +1,68 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Http.Result +{ + internal sealed class AcceptedAtRouteResult : ObjectResult + { + /// + /// Initializes a new instance of the class with the values + /// provided. + /// + /// The route data to use for generating the URL. + /// The value to format in the entity body. + public AcceptedAtRouteResult(object? routeValues, object? value) + : this(routeName: null, routeValues: routeValues, value: value) + { + } + + /// + /// Initializes a new instance of the class with the values + /// provided. + /// + /// The name of the route to use for generating the URL. + /// The route data to use for generating the URL. + /// The value to format in the entity body. + public AcceptedAtRouteResult( + string? routeName, + object? routeValues, + object? value) + : base(value, StatusCodes.Status202Accepted) + { + RouteName = routeName; + RouteValues = new RouteValueDictionary(routeValues); + } + + /// + /// Gets the name of the route to use for generating the URL. + /// + public string? RouteName { get; } + + /// + /// Gets the route data to use for generating the URL. + /// + public RouteValueDictionary RouteValues { get; } + + /// + protected override void OnFormatting(HttpContext context) + { + var linkGenerator = context.RequestServices.GetRequiredService(); + var url = linkGenerator.GetUriByAddress( + context, + RouteName, + RouteValues, + fragment: FragmentString.Empty); + + if (string.IsNullOrEmpty(url)) + { + throw new InvalidOperationException("No route matches the supplied values."); + } + + context.Response.Headers.Location = url; + } + } +} diff --git a/src/Http/Http.Results/src/AcceptedResult.cs b/src/Http/Http.Results/src/AcceptedResult.cs new file mode 100644 index 000000000000..d7e1da54e514 --- /dev/null +++ b/src/Http/Http.Results/src/AcceptedResult.cs @@ -0,0 +1,74 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNetCore.Http.Result +{ + internal sealed class AcceptedResult : ObjectResult + { + /// + /// Initializes a new instance of the class with the values + /// provided. + /// + public AcceptedResult() + : base(value: null, StatusCodes.Status202Accepted) + { + } + + /// + /// Initializes a new instance of the class with the values + /// provided. + /// + /// The location at which the status of requested content can be monitored. + /// The value to format in the entity body. + public AcceptedResult(string? location, object? value) + : base(value, StatusCodes.Status202Accepted) + { + Location = location; + } + + /// + /// Initializes a new instance of the class with the values + /// provided. + /// + /// The location at which the status of requested content can be monitored. + /// The value to format in the entity body. + public AcceptedResult(Uri locationUri, object? value) + : base(value, StatusCodes.Status202Accepted) + { + if (locationUri == null) + { + throw new ArgumentNullException(nameof(locationUri)); + } + + if (locationUri.IsAbsoluteUri) + { + Location = locationUri.AbsoluteUri; + } + else + { + Location = locationUri.GetComponents(UriComponents.SerializationInfoString, UriFormat.UriEscaped); + } + } + + /// + /// Gets or sets the location at which the status of the requested content can be monitored. + /// + public string? Location { get; set; } + + /// + protected override void OnFormatting(HttpContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (!string.IsNullOrEmpty(Location)) + { + context.Response.Headers.Location = Location; + } + } + } +} diff --git a/src/Http/Http.Results/src/BadRequestObjectResult.cs b/src/Http/Http.Results/src/BadRequestObjectResult.cs new file mode 100644 index 000000000000..2599ec549c6c --- /dev/null +++ b/src/Http/Http.Results/src/BadRequestObjectResult.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Http.Result +{ + internal sealed class BadRequestObjectResult : ObjectResult + { + public BadRequestObjectResult(object? error) + : base(error, StatusCodes.Status400BadRequest) + { + } + } +} diff --git a/src/Http/Http.Results/src/BadRequestResult.cs b/src/Http/Http.Results/src/BadRequestResult.cs new file mode 100644 index 000000000000..eff601caa1f0 --- /dev/null +++ b/src/Http/Http.Results/src/BadRequestResult.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Http.Result +{ + internal sealed class BadRequestResult : StatusCodeResult + { + public BadRequestResult() : base(StatusCodes.Status400BadRequest) + { + } + } +} diff --git a/src/Http/Http.Results/src/ChallengeResult.cs b/src/Http/Http.Results/src/ChallengeResult.cs new file mode 100644 index 000000000000..e3614755e937 --- /dev/null +++ b/src/Http/Http.Results/src/ChallengeResult.cs @@ -0,0 +1,120 @@ +// Copyright (c) .NET Foundation. 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.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Http.Result +{ + /// + /// An that on execution invokes . + /// + internal sealed partial class ChallengeResult : IResult + { + /// + /// Initializes a new instance of . + /// + public ChallengeResult() + : this(Array.Empty()) + { + } + + /// + /// Initializes a new instance of with the + /// specified authentication scheme. + /// + /// The authentication scheme to challenge. + public ChallengeResult(string authenticationScheme) + : this(new[] { authenticationScheme }) + { + } + + /// + /// Initializes a new instance of with the + /// specified authentication schemes. + /// + /// The authentication schemes to challenge. + public ChallengeResult(IList authenticationSchemes) + : this(authenticationSchemes, properties: null) + { + } + + /// + /// Initializes a new instance of with the + /// specified . + /// + /// used to perform the authentication + /// challenge. + public ChallengeResult(AuthenticationProperties? properties) + : this(Array.Empty(), properties) + { + } + + /// + /// Initializes a new instance of with the + /// specified authentication scheme and . + /// + /// The authentication schemes to challenge. + /// used to perform the authentication + /// challenge. + public ChallengeResult(string authenticationScheme, AuthenticationProperties? properties) + : this(new[] { authenticationScheme }, properties) + { + } + + /// + /// Initializes a new instance of with the + /// specified authentication schemes and . + /// + /// The authentication scheme to challenge. + /// used to perform the authentication + /// challenge. + public ChallengeResult(IList authenticationSchemes, AuthenticationProperties? properties) + { + AuthenticationSchemes = authenticationSchemes; + Properties = properties; + } + + public IList AuthenticationSchemes { get; init; } = Array.Empty(); + + public AuthenticationProperties? Properties { get; init; } + + public async Task ExecuteAsync(HttpContext httpContext) + { + var logger = httpContext.RequestServices.GetRequiredService>(); + + Log.ChallengeResultExecuting(logger, AuthenticationSchemes); + + if (AuthenticationSchemes != null && AuthenticationSchemes.Count > 0) + { + foreach (var scheme in AuthenticationSchemes) + { + await httpContext.ChallengeAsync(scheme, Properties); + } + } + else + { + await httpContext.ChallengeAsync(Properties); + } + } + + private static partial class Log + { + public static void ChallengeResultExecuting(ILogger logger, IList authenticationSchemes) + { + if (logger.IsEnabled(LogLevel.Information)) + { + ChallengeResultExecuting(logger, authenticationSchemes.ToArray()); + } + } + + [LoggerMessage(1, LogLevel.Information, "Executing ChallengeResult with authentication schemes ({Schemes}).", EventName = "ChallengeResultExecuting", SkipEnabledCheck = true)] + private static partial void ChallengeResultExecuting(ILogger logger, string[] schemes); + } + } +} diff --git a/src/Http/Http.Results/src/ConflictObjectResult.cs b/src/Http/Http.Results/src/ConflictObjectResult.cs new file mode 100644 index 000000000000..7e5d8faed423 --- /dev/null +++ b/src/Http/Http.Results/src/ConflictObjectResult.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Http.Result +{ + internal sealed class ConflictObjectResult : ObjectResult + { + public ConflictObjectResult(object? error) : + base(error, StatusCodes.Status409Conflict) + { + } + } +} diff --git a/src/Http/Http.Results/src/ConflictResult.cs b/src/Http/Http.Results/src/ConflictResult.cs new file mode 100644 index 000000000000..2baf7d079fb0 --- /dev/null +++ b/src/Http/Http.Results/src/ConflictResult.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Http.Result +{ + internal class ConflictResult : StatusCodeResult + { + public ConflictResult() : base(StatusCodes.Status409Conflict) + { + } + } +} diff --git a/src/Http/Http.Results/src/ContentResult.cs b/src/Http/Http.Results/src/ContentResult.cs new file mode 100644 index 000000000000..12f1ceb24f36 --- /dev/null +++ b/src/Http/Http.Results/src/ContentResult.cs @@ -0,0 +1,75 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Http.Result +{ + internal sealed partial class ContentResult : IResult + { + private const string DefaultContentType = "text/plain; charset=utf-8"; + private static readonly Encoding DefaultEncoding = Encoding.UTF8; + + /// + /// Gets or set the content representing the body of the response. + /// + public string? Content { get; init; } + + /// + /// Gets or sets the Content-Type header for the response. + /// + public string? ContentType { get; init; } + + /// + /// Gets or sets the HTTP status code. + /// + public int? StatusCode { get; init; } + + /// + /// Writes the content to the HTTP response. + /// + /// The for the current request. + /// A task that represents the asynchronous execute operation. + public async Task ExecuteAsync(HttpContext httpContext) + { + var response = httpContext.Response; + + ResponseContentTypeHelper.ResolveContentTypeAndEncoding( + ContentType, + response.ContentType, + (DefaultContentType, DefaultEncoding), + ResponseContentTypeHelper.GetEncoding, + out var resolvedContentType, + out var resolvedContentTypeEncoding); + + response.ContentType = resolvedContentType; + + if (StatusCode != null) + { + response.StatusCode = StatusCode.Value; + } + + var logger = httpContext.RequestServices.GetRequiredService>(); + + Log.ContentResultExecuting(logger, resolvedContentType); + + if (Content != null) + { + response.ContentLength = resolvedContentTypeEncoding.GetByteCount(Content); + await response.WriteAsync(Content, resolvedContentTypeEncoding); + } + } + + private static partial class Log + { + [LoggerMessage(1, LogLevel.Information, + "Executing ContentResult with HTTP Response ContentType of {ContentType}", + EventName = "ContentResultExecuting")] + internal static partial void ContentResultExecuting(ILogger logger, string contentType); + } + } +} diff --git a/src/Http/Http.Results/src/CreatedAtRouteResult.cs b/src/Http/Http.Results/src/CreatedAtRouteResult.cs new file mode 100644 index 000000000000..8fe982930663 --- /dev/null +++ b/src/Http/Http.Results/src/CreatedAtRouteResult.cs @@ -0,0 +1,68 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Http.Result +{ + internal sealed class CreatedAtRouteResult : ObjectResult + { + /// + /// Initializes a new instance of the class with the values + /// provided. + /// + /// The route data to use for generating the URL. + /// The value to format in the entity body. + public CreatedAtRouteResult(object? routeValues, object? value) + : this(routeName: null, routeValues: routeValues, value: value) + { + } + + /// + /// Initializes a new instance of the class with the values + /// provided. + /// + /// The name of the route to use for generating the URL. + /// The route data to use for generating the URL. + /// The value to format in the entity body. + public CreatedAtRouteResult( + string? routeName, + object? routeValues, + object? value) + : base(value, StatusCodes.Status201Created) + { + RouteName = routeName; + RouteValues = routeValues == null ? null : new RouteValueDictionary(routeValues); + } + + /// + /// Gets or sets the name of the route to use for generating the URL. + /// + public string? RouteName { get; set; } + + /// + /// Gets or sets the route data to use for generating the URL. + /// + public RouteValueDictionary? RouteValues { get; set; } + + /// + protected override void OnFormatting(HttpContext context) + { + var linkGenerator = context.RequestServices.GetRequiredService(); + var url = linkGenerator.GetUriByRouteValues( + context, + RouteName, + RouteValues, + fragment: FragmentString.Empty); + + if (string.IsNullOrEmpty(url)) + { + throw new InvalidOperationException("No route matches the supplied values."); + } + + context.Response.Headers.Location = url; + } + } +} diff --git a/src/Http/Http.Results/src/CreatedResult.cs b/src/Http/Http.Results/src/CreatedResult.cs new file mode 100644 index 000000000000..23be3d084722 --- /dev/null +++ b/src/Http/Http.Results/src/CreatedResult.cs @@ -0,0 +1,57 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNetCore.Http.Result +{ + internal sealed class CreatedResult : ObjectResult + { + /// + /// Initializes a new instance of the class with the values + /// provided. + /// + /// The location at which the content has been created. + /// The value to format in the entity body. + public CreatedResult(string location, object? value) + : base(value, StatusCodes.Status201Created) + { + Location = location; + } + + /// + /// Initializes a new instance of the class with the values + /// provided. + /// + /// The location at which the content has been created. + /// The value to format in the entity body. + public CreatedResult(Uri location, object? value) + : base(value, StatusCodes.Status201Created) + { + if (location == null) + { + throw new ArgumentNullException(nameof(location)); + } + + if (location.IsAbsoluteUri) + { + Location = location.AbsoluteUri; + } + else + { + Location = location.GetComponents(UriComponents.SerializationInfoString, UriFormat.UriEscaped); + } + } + + /// + /// Gets or sets the location at which the content has been created. + /// + public string Location { get; init; } + + /// + protected override void OnFormatting(HttpContext context) + { + context.Response.Headers.Location = Location; + } + } +} diff --git a/src/Http/Http.Results/src/FileContentResult.cs b/src/Http/Http.Results/src/FileContentResult.cs new file mode 100644 index 000000000000..cc51ed505112 --- /dev/null +++ b/src/Http/Http.Results/src/FileContentResult.cs @@ -0,0 +1,74 @@ +// Copyright (c) .NET Foundation. 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 System.Threading.Tasks; +using Microsoft.AspNetCore.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Http.Result +{ + internal sealed partial class FileContentResult : FileResult, IResult + { + /// + /// Creates a new instance with + /// the provided and the + /// provided . + /// + /// The bytes that represent the file contents. + /// The Content-Type header of the response. + public FileContentResult(byte[] fileContents, string contentType) + : base(contentType) + { + FileContents = fileContents; + } + + /// + /// Gets or sets the file contents. + /// + public byte[] FileContents { get; init; } + + public Task ExecuteAsync(HttpContext httpContext) + { + var logger = httpContext.RequestServices.GetRequiredService>(); + Log.ExecutingFileResult(logger, this); + + var fileResultInfo = new FileResultInfo + { + ContentType = ContentType, + EnableRangeProcessing = EnableRangeProcessing, + EntityTag = EntityTag, + FileDownloadName = FileDownloadName, + LastModified = LastModified, + }; + + var (range, rangeLength, serveBody) = FileResultHelper.SetHeadersAndLog( + httpContext, + fileResultInfo, + FileContents.Length, + EnableRangeProcessing, + LastModified, + EntityTag, + logger); + + if (!serveBody) + { + return Task.CompletedTask; + } + + if (range != null && rangeLength == 0) + { + return Task.CompletedTask; + } + + if (range != null) + { + FileResultHelper.Log.WritingRangeToBody(logger); + } + + var fileContentStream = new MemoryStream(FileContents); + return FileResultHelper.WriteFileAsync(httpContext, fileContentStream, range, rangeLength); + } + } +} diff --git a/src/Http/Http.Results/src/FileResult.cs b/src/Http/Http.Results/src/FileResult.cs new file mode 100644 index 000000000000..1e0afe40a1f7 --- /dev/null +++ b/src/Http/Http.Results/src/FileResult.cs @@ -0,0 +1,93 @@ +// Copyright (c) .NET Foundation. 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.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Http.Result +{ + internal abstract partial class FileResult + { + private string? _fileDownloadName; + + /// + /// Creates a new instance with + /// the provided . + /// + /// The Content-Type header of the response. + protected FileResult(string contentType) + { + if (contentType == null) + { + throw new ArgumentNullException(nameof(contentType)); + } + + ContentType = contentType; + } + + /// + /// Gets the Content-Type header for the response. + /// + public string ContentType { get; } + + /// + /// Gets the file name that will be used in the Content-Disposition header of the response. + /// + [AllowNull] + public string FileDownloadName + { + get { return _fileDownloadName ?? string.Empty; } + init { _fileDownloadName = value; } + } + + /// + /// Gets or sets the last modified information associated with the . + /// + public DateTimeOffset? LastModified { get; init; } + + /// + /// Gets or sets the etag associated with the . + /// + public EntityTagHeaderValue? EntityTag { get; init; } + + /// + /// Gets or sets the value that enables range processing for the . + /// + public bool EnableRangeProcessing { get; init; } + + protected static partial class Log + { + public static void ExecutingFileResult(ILogger logger, FileResult fileResult) + { + if (logger.IsEnabled(LogLevel.Information)) + { + var fileResultType = fileResult.GetType().Name; + ExecutingFileResultWithNoFileName(logger, fileResultType, fileResult.FileDownloadName); + } + } + + public static void ExecutingFileResult(ILogger logger, FileResult fileResult, string fileName) + { + if (logger.IsEnabled(LogLevel.Information)) + { + var fileResultType = fileResult.GetType().Name; + ExecutingFileResult(logger, fileResultType, fileName, fileResult.FileDownloadName); + } + } + + [LoggerMessage(1, LogLevel.Information, + "Executing {FileResultType}, sending file with download name '{FileDownloadName}'.", + EventName = "ExecutingFileResultWithNoFileName", + SkipEnabledCheck = true)] + private static partial void ExecutingFileResultWithNoFileName(ILogger logger, string fileResultType, string fileDownloadName); + + [LoggerMessage(2, LogLevel.Information, + "Executing {FileResultType}, sending file '{FileDownloadPath}' with download name '{FileDownloadName}'.", + EventName = "ExecutingFileResult", + SkipEnabledCheck = true)] + private static partial void ExecutingFileResult(ILogger logger, string fileResultType, string fileDownloadPath, string fileDownloadName); + } + } +} diff --git a/src/Http/Http.Results/src/FileStreamResult.cs b/src/Http/Http.Results/src/FileStreamResult.cs new file mode 100644 index 000000000000..7b7862047ed1 --- /dev/null +++ b/src/Http/Http.Results/src/FileStreamResult.cs @@ -0,0 +1,110 @@ +// Copyright (c) .NET Foundation. 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 System.Threading.Tasks; +using Microsoft.AspNetCore.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Http.Result +{ + /// + /// Represents an that when executed will + /// write a file from a stream to the response. + /// + internal sealed class FileStreamResult : FileResult, IResult + { + /// + /// Creates a new instance with + /// the provided and the + /// provided . + /// + /// The stream with the file. + /// The Content-Type header of the response. + public FileStreamResult(Stream fileStream, string contentType) + : this(fileStream, MediaTypeHeaderValue.Parse(contentType)) + { + if (fileStream == null) + { + throw new ArgumentNullException(nameof(fileStream)); + } + + FileStream = fileStream; + } + + /// + /// Creates a new instance with + /// the provided and the + /// provided . + /// + /// The stream with the file. + /// The Content-Type header of the response. + public FileStreamResult(Stream fileStream, MediaTypeHeaderValue contentType) + : base(contentType.ToString()) + { + if (fileStream == null) + { + throw new ArgumentNullException(nameof(fileStream)); + } + + FileStream = fileStream; + } + + /// + /// Gets or sets the stream with the file that will be sent back as the response. + /// + public Stream FileStream { get; } + + public Task ExecuteAsync(HttpContext httpContext) + { + var logger = httpContext.RequestServices.GetRequiredService>(); + using (FileStream) + { + Log.ExecutingFileResult(logger, this); + + long? fileLength = null; + if (FileStream.CanSeek) + { + fileLength = FileStream.Length; + } + + var fileResultInfo = new FileResultInfo + { + ContentType = ContentType, + EnableRangeProcessing = EnableRangeProcessing, + EntityTag = EntityTag, + FileDownloadName = FileDownloadName, + }; + + var (range, rangeLength, serveBody) = FileResultHelper.SetHeadersAndLog( + httpContext, + fileResultInfo, + fileLength, + EnableRangeProcessing, + LastModified, + EntityTag, + logger); + + if (!serveBody) + { + return Task.CompletedTask; + } + + if (range != null && rangeLength == 0) + { + return Task.CompletedTask; + } + + if (range != null) + { + FileResultHelper.Log.WritingRangeToBody(logger); + } + + return FileResultHelper.WriteFileAsync(httpContext, FileStream, range, rangeLength); + } + } + } +} diff --git a/src/Http/Http.Results/src/ForbidResult.cs b/src/Http/Http.Results/src/ForbidResult.cs new file mode 100644 index 000000000000..9a22b6240d3b --- /dev/null +++ b/src/Http/Http.Results/src/ForbidResult.cs @@ -0,0 +1,125 @@ +// Copyright (c) .NET Foundation. 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.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Http.Result +{ + internal sealed partial class ForbidResult : IResult + { + /// + /// Initializes a new instance of . + /// + public ForbidResult() + : this(Array.Empty()) + { + } + + /// + /// Initializes a new instance of with the + /// specified authentication scheme. + /// + /// The authentication scheme to challenge. + public ForbidResult(string authenticationScheme) + : this(new[] { authenticationScheme }) + { + } + + /// + /// Initializes a new instance of with the + /// specified authentication schemes. + /// + /// The authentication schemes to challenge. + public ForbidResult(IList authenticationSchemes) + : this(authenticationSchemes, properties: null) + { + } + + /// + /// Initializes a new instance of with the + /// specified . + /// + /// used to perform the authentication + /// challenge. + public ForbidResult(AuthenticationProperties? properties) + : this(Array.Empty(), properties) + { + } + + /// + /// Initializes a new instance of with the + /// specified authentication scheme and . + /// + /// The authentication schemes to challenge. + /// used to perform the authentication + /// challenge. + public ForbidResult(string authenticationScheme, AuthenticationProperties? properties) + : this(new[] { authenticationScheme }, properties) + { + } + + /// + /// Initializes a new instance of with the + /// specified authentication schemes and . + /// + /// The authentication scheme to challenge. + /// used to perform the authentication + /// challenge. + public ForbidResult(IList authenticationSchemes, AuthenticationProperties? properties) + { + AuthenticationSchemes = authenticationSchemes; + Properties = properties; + } + + /// + /// Gets or sets the authentication schemes that are challenged. + /// + public IList AuthenticationSchemes { get; init; } + + /// + /// Gets or sets the used to perform the authentication challenge. + /// + public AuthenticationProperties? Properties { get; init; } + + /// + public async Task ExecuteAsync(HttpContext httpContext) + { + var logger = httpContext.RequestServices.GetRequiredService>(); + + Log.ForbidResultExecuting(logger, AuthenticationSchemes); + + if (AuthenticationSchemes != null && AuthenticationSchemes.Count > 0) + { + for (var i = 0; i < AuthenticationSchemes.Count; i++) + { + await httpContext.ForbidAsync(AuthenticationSchemes[i], Properties); + } + } + else + { + await httpContext.ForbidAsync(Properties); + } + } + + private static partial class Log + { + public static void ForbidResultExecuting(ILogger logger, IList authenticationSchemes) + { + if (logger.IsEnabled(LogLevel.Information)) + { + ForbidResultExecuting(logger, authenticationSchemes.ToArray()); + } + } + + [LoggerMessage(1, LogLevel.Information, "Executing ChallengeResult with authentication schemes ({Schemes}).", EventName = "ChallengeResultExecuting", SkipEnabledCheck = true)] + private static partial void ForbidResultExecuting(ILogger logger, string[] schemes); + } + + } +} diff --git a/src/Http/Http.Results/src/JsonResult.cs b/src/Http/Http.Results/src/JsonResult.cs new file mode 100644 index 000000000000..597048c2b627 --- /dev/null +++ b/src/Http/Http.Results/src/JsonResult.cs @@ -0,0 +1,78 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Http.Result +{ + /// + /// An action result which formats the given object as JSON. + /// + internal sealed partial class JsonResult : IResult + { + /// + /// Gets or sets the representing the Content-Type header of the response. + /// + public string? ContentType { get; init; } + + /// + /// Gets or sets the serializer settings. + /// + /// When using System.Text.Json, this should be an instance of + /// + /// + /// When using Newtonsoft.Json, this should be an instance of JsonSerializerSettings. + /// + /// + public JsonSerializerOptions? JsonSerializerOptions { get; init; } + + /// + /// Gets or sets the HTTP status code. + /// + public int? StatusCode { get; init; } + + /// + /// Gets or sets the value to be formatted. + /// + public object? Value { get; init; } + + /// + /// Write the result as JSON to the HTTP response. + /// + /// The for the current request. + /// A task that represents the asynchronous execute operation. + Task IResult.ExecuteAsync(HttpContext httpContext) + { + var logger = httpContext.RequestServices.GetRequiredService>(); + Log.JsonResultExecuting(logger, Value); + + if (StatusCode is int statusCode) + { + httpContext.Response.StatusCode = statusCode; + } + + return httpContext.Response.WriteAsJsonAsync(Value, JsonSerializerOptions, ContentType); + } + + private static partial class Log + { + public static void JsonResultExecuting(ILogger logger, object? value) + { + if (logger.IsEnabled(LogLevel.Information)) + { + var type = value == null ? "null" : value.GetType().FullName!; + JsonResultExecuting(logger, type); + } + } + + [LoggerMessage(1, LogLevel.Information, + "Executing JsonResult, writing value of type '{Type}'.", + EventName = "JsonResultExecuting", + SkipEnabledCheck = true)] + private static partial void JsonResultExecuting(ILogger logger, string type); + } + } +} diff --git a/src/Http/Http.Results/src/LocalRedirectResult.cs b/src/Http/Http.Results/src/LocalRedirectResult.cs new file mode 100644 index 000000000000..51aa27568b2e --- /dev/null +++ b/src/Http/Http.Results/src/LocalRedirectResult.cs @@ -0,0 +1,111 @@ +// Copyright (c) .NET Foundation. 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.Threading.Tasks; +using Microsoft.AspNetCore.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Http.Result +{ + /// + /// An that returns a Found (302), Moved Permanently (301), Temporary Redirect (307), + /// or Permanent Redirect (308) response with a Location header to the supplied local URL. + /// + internal sealed partial class LocalRedirectResult : IResult + { + /// + /// Initializes a new instance of the class with the values + /// provided. + /// + /// The local URL to redirect to. + public LocalRedirectResult(string localUrl) + : this(localUrl, permanent: false) + { + } + + /// + /// Initializes a new instance of the class with the values + /// provided. + /// + /// The local URL to redirect to. + /// Specifies whether the redirect should be permanent (301) or temporary (302). + public LocalRedirectResult(string localUrl, bool permanent) + : this(localUrl, permanent, preserveMethod: false) + { + } + + /// + /// Initializes a new instance of the class with the values + /// provided. + /// + /// The local URL to redirect to. + /// Specifies whether the redirect should be permanent (301) or temporary (302). + /// If set to true, make the temporary redirect (307) or permanent redirect (308) preserve the initial request's method. + public LocalRedirectResult(string localUrl, bool permanent, bool preserveMethod) + { + if (string.IsNullOrEmpty(localUrl)) + { + throw new ArgumentException("Argument cannot be null or empty", nameof(localUrl)); + } + + Permanent = permanent; + PreserveMethod = preserveMethod; + Url = localUrl; + } + + /// + /// Gets or sets the value that specifies that the redirect should be permanent if true or temporary if false. + /// + public bool Permanent { get; } + + /// + /// Gets or sets an indication that the redirect preserves the initial request method. + /// + public bool PreserveMethod { get; } + + /// + /// Gets or sets the local URL to redirect to. + /// + public string Url { get; } + + /// + public Task ExecuteAsync(HttpContext httpContext) + { + if (!SharedUrlHelper.IsLocalUrl(Url)) + { + throw new InvalidOperationException("The supplied URL is not local. A URL with an absolute path is considered local if it does not have a host/authority part. URLs using virtual paths ('~/') are also local."); + } + + var destinationUrl = SharedUrlHelper.Content(httpContext, Url); + + // IsLocalUrl is called to handle URLs starting with '~/'. + var logger = httpContext.RequestServices.GetRequiredService>(); + + Log.LocalRedirectResultExecuting(logger, destinationUrl); + + if (PreserveMethod) + { + httpContext.Response.StatusCode = Permanent + ? StatusCodes.Status308PermanentRedirect + : StatusCodes.Status307TemporaryRedirect; + httpContext.Response.Headers.Location = destinationUrl; + } + else + { + httpContext.Response.Redirect(destinationUrl, Permanent); + } + + return Task.CompletedTask; + } + + private static partial class Log + { + [LoggerMessage(1, LogLevel.Information, + "Executing LocalRedirectResult, redirecting to {Destination}.", + EventName = "LocalRedirectResultExecuting")] + public static partial void LocalRedirectResultExecuting(ILogger logger, string destination); + } + } +} diff --git a/src/Http/Http.Results/src/Microsoft.AspNetCore.Http.Results.csproj b/src/Http/Http.Results/src/Microsoft.AspNetCore.Http.Results.csproj new file mode 100644 index 000000000000..54b85a37bbfb --- /dev/null +++ b/src/Http/Http.Results/src/Microsoft.AspNetCore.Http.Results.csproj @@ -0,0 +1,31 @@ + + + + ASP.NET Core types that implement Microsoft.AspNetCore.Http.IResult. + $(DefaultNetCoreTargetFramework) + true + true + aspnetcore + false + enable + Microsoft.AspNetCore.Http.Result + + + + + + + + + + + + + + + + + + + + diff --git a/src/Http/Http.Results/src/NoContentResult.cs b/src/Http/Http.Results/src/NoContentResult.cs new file mode 100644 index 000000000000..120ce546ef09 --- /dev/null +++ b/src/Http/Http.Results/src/NoContentResult.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Http.Result +{ + internal class NoContentResult : StatusCodeResult + { + public NoContentResult() : base(StatusCodes.Status204NoContent) + { + } + } +} diff --git a/src/Http/Http.Results/src/NotFoundObjectResult.cs b/src/Http/Http.Results/src/NotFoundObjectResult.cs new file mode 100644 index 000000000000..fbda773257c0 --- /dev/null +++ b/src/Http/Http.Results/src/NotFoundObjectResult.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Http.Result +{ + internal sealed class NotFoundObjectResult : ObjectResult + { + public NotFoundObjectResult(object? value) + : base(value, StatusCodes.Status404NotFound) + { + } + } +} diff --git a/src/Http/Http.Results/src/NotFoundResult.cs b/src/Http/Http.Results/src/NotFoundResult.cs new file mode 100644 index 000000000000..b0d7902eb8e4 --- /dev/null +++ b/src/Http/Http.Results/src/NotFoundResult.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Http.Result +{ + internal sealed class NotFoundResult : StatusCodeResult + { + public NotFoundResult() : base(StatusCodes.Status404NotFound) + { + } + } +} diff --git a/src/Http/Http.Results/src/ObjectResult.cs b/src/Http/Http.Results/src/ObjectResult.cs new file mode 100644 index 000000000000..e01a0f6ffd48 --- /dev/null +++ b/src/Http/Http.Results/src/ObjectResult.cs @@ -0,0 +1,62 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Http.Result +{ + internal partial class ObjectResult : IResult + { + /// + /// Creates a new instance with the provided . + /// + public ObjectResult(object? value, int statusCode) + { + Value = value; + StatusCode = statusCode; + } + + /// + /// The object result. + /// + public object? Value { get; } + + /// + /// Gets or sets the HTTP status code. + /// + public int StatusCode { get; } + + public Task ExecuteAsync(HttpContext httpContext) + { + var loggerFactory = httpContext.RequestServices.GetRequiredService(); + var logger = loggerFactory.CreateLogger(GetType()); + Log.ObjectResultExecuting(logger, Value); + + httpContext.Response.StatusCode = StatusCode; + + OnFormatting(httpContext); + return httpContext.Response.WriteAsJsonAsync(Value); + } + + protected virtual void OnFormatting(HttpContext httpContext) + { + } + + private static partial class Log + { + public static void ObjectResultExecuting(ILogger logger, object? value) + { + if (logger.IsEnabled(LogLevel.Information)) + { + var valueType = value is null ? "null" : value.GetType().FullName!; + ObjectResultExecuting(logger, valueType); + } + } + + [LoggerMessage(1, LogLevel.Information, "Writing value of type '{Type}'.", EventName = "ObjectResultExecuting", SkipEnabledCheck = true)] + public static partial void ObjectResultExecuting(ILogger logger, string type); + } + } +} diff --git a/src/Http/Http.Results/src/OkObjectResult.cs b/src/Http/Http.Results/src/OkObjectResult.cs new file mode 100644 index 000000000000..f7ec6e9603bc --- /dev/null +++ b/src/Http/Http.Results/src/OkObjectResult.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Http.Result +{ + internal sealed class OkObjectResult : ObjectResult + { + public OkObjectResult(object? value) + : base(value, StatusCodes.Status200OK) + { + } + } +} diff --git a/src/Http/Http.Results/src/OkResult.cs b/src/Http/Http.Results/src/OkResult.cs new file mode 100644 index 000000000000..9cf0430fa990 --- /dev/null +++ b/src/Http/Http.Results/src/OkResult.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Http.Result +{ + internal class OkResult : StatusCodeResult + { + public OkResult() : base(StatusCodes.Status200OK) + { + } + } +} diff --git a/src/Http/Http.Results/src/PhysicalFileResult.cs b/src/Http/Http.Results/src/PhysicalFileResult.cs new file mode 100644 index 000000000000..9a53b91e9466 --- /dev/null +++ b/src/Http/Http.Results/src/PhysicalFileResult.cs @@ -0,0 +1,123 @@ +// Copyright (c) .NET Foundation. 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 System.Threading.Tasks; +using Microsoft.AspNetCore.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Http.Result +{ + /// + /// A on execution will write a file from disk to the response + /// using mechanisms provided by the host. + /// + internal sealed partial class PhysicalFileResult : FileResult, IResult + { + /// + /// Creates a new instance with + /// the provided and the provided . + /// + /// The path to the file. The path must be an absolute path. + /// The Content-Type header of the response. + public PhysicalFileResult(string fileName, string contentType) + : base(contentType) + { + FileName = fileName; + } + + /// + /// Gets or sets the path to the file that will be sent back as the response. + /// + public string FileName { get; } + + // For testing + public Func GetFileInfoWrapper { get; init; } = + static path => new FileInfoWrapper(path); + + public Task ExecuteAsync(HttpContext httpContext) + { + var fileInfo = GetFileInfoWrapper(FileName); + if (!fileInfo.Exists) + { + throw new FileNotFoundException($"Could not find file: {FileName}", FileName); + } + + var logger = httpContext.RequestServices.GetRequiredService>(); + + Log.ExecutingFileResult(logger, this, FileName); + + var lastModified = LastModified ?? fileInfo.LastWriteTimeUtc; + var fileResultInfo = new FileResultInfo + { + ContentType = ContentType, + EnableRangeProcessing = EnableRangeProcessing, + EntityTag = EntityTag, + FileDownloadName = FileDownloadName, + LastModified = lastModified, + }; + + var (range, rangeLength, serveBody) = FileResultHelper.SetHeadersAndLog( + httpContext, + fileResultInfo, + fileInfo.Length, + EnableRangeProcessing, + lastModified, + EntityTag, + logger); + + if (!serveBody) + { + return Task.CompletedTask; + } + + if (range != null && rangeLength == 0) + { + return Task.CompletedTask; + } + + var response = httpContext.Response; + if (!Path.IsPathRooted(FileName)) + { + throw new NotSupportedException($"Path '{FileName}' was not rooted."); + } + + if (range != null) + { + FileResultHelper.Log.WritingRangeToBody(logger); + } + + var offset = 0L; + var count = (long?)null; + if (range != null) + { + offset = range.From ?? 0L; + count = rangeLength; + } + + return response.SendFileAsync( + FileName, + offset: offset, + count: count); + } + + internal readonly struct FileInfoWrapper + { + public FileInfoWrapper(string path) + { + var fileInfo = new FileInfo(path); + Exists = fileInfo.Exists; + Length = fileInfo.Length; + LastWriteTimeUtc = fileInfo.LastWriteTimeUtc; + } + + public bool Exists { get; init; } + + public long Length { get; init; } + + public DateTimeOffset LastWriteTimeUtc { get; init; } + } + } +} diff --git a/src/Http/Http.Results/src/PublicAPI.Shipped.txt b/src/Http/Http.Results/src/PublicAPI.Shipped.txt new file mode 100644 index 000000000000..7dc5c58110bf --- /dev/null +++ b/src/Http/Http.Results/src/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Http/Http.Results/src/PublicAPI.Unshipped.txt b/src/Http/Http.Results/src/PublicAPI.Unshipped.txt new file mode 100644 index 000000000000..dffa3a246402 --- /dev/null +++ b/src/Http/Http.Results/src/PublicAPI.Unshipped.txt @@ -0,0 +1,104 @@ +#nullable enable +Microsoft.AspNetCore.Http.Results +static Microsoft.AspNetCore.Http.Results.Accepted() -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Accepted(System.Uri! uri) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Accepted(System.Uri! uri, object? value) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Accepted(object? value) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Accepted(string? uri) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Accepted(string? uri, object? value) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.AcceptedAtRoute(object? routeValues) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.AcceptedAtRoute(object? routeValues, object? value) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.AcceptedAtRoute(string? routeName) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.AcceptedAtRoute(string? routeName, object? routeValues) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.AcceptedAtRoute(string? routeName, object? routeValues, object? value) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.BadRequest() -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.BadRequest(object? error) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Challenge() -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Challenge(Microsoft.AspNetCore.Authentication.AuthenticationProperties! properties) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Challenge(Microsoft.AspNetCore.Authentication.AuthenticationProperties! properties, params string![]! authenticationSchemes) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Challenge(params string![]! authenticationSchemes) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Conflict() -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Conflict(object? error) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Content(string! content) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Content(string! content, Microsoft.Net.Http.Headers.MediaTypeHeaderValue? contentType) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Content(string! content, string! contentType) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Content(string! content, string! contentType, System.Text.Encoding! contentEncoding) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Created(System.Uri! uri, object? value) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Created(string! uri, object? value) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.CreatedAtRoute(object? routeValues, object? value) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.CreatedAtRoute(string? routeName, object? routeValues, object? value) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.CreatedAtRoute(string? routeName, object? value) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(System.IO.Stream! fileStream, string! contentType) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(System.IO.Stream! fileStream, string! contentType, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(System.IO.Stream! fileStream, string! contentType, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag, bool enableRangeProcessing) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(System.IO.Stream! fileStream, string! contentType, bool enableRangeProcessing) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(System.IO.Stream! fileStream, string! contentType, string? fileDownloadName) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(System.IO.Stream! fileStream, string! contentType, string? fileDownloadName, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(System.IO.Stream! fileStream, string! contentType, string? fileDownloadName, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag, bool enableRangeProcessing) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(System.IO.Stream! fileStream, string! contentType, string? fileDownloadName, bool enableRangeProcessing) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(byte[]! fileContents, string! contentType) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(byte[]! fileContents, string! contentType, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(byte[]! fileContents, string! contentType, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag, bool enableRangeProcessing) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(byte[]! fileContents, string! contentType, bool enableRangeProcessing) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(byte[]! fileContents, string! contentType, string? fileDownloadName) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(byte[]! fileContents, string! contentType, string? fileDownloadName, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(byte[]! fileContents, string! contentType, string? fileDownloadName, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag, bool enableRangeProcessing) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(byte[]! fileContents, string! contentType, string? fileDownloadName, bool enableRangeProcessing) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(string! virtualPath, string! contentType) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(string! virtualPath, string! contentType, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(string! virtualPath, string! contentType, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag, bool enableRangeProcessing) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(string! virtualPath, string! contentType, bool enableRangeProcessing) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(string! virtualPath, string! contentType, string? fileDownloadName) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(string! virtualPath, string! contentType, string? fileDownloadName, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(string! virtualPath, string! contentType, string? fileDownloadName, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag, bool enableRangeProcessing) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.File(string! virtualPath, string! contentType, string? fileDownloadName, bool enableRangeProcessing) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Forbid() -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Forbid(Microsoft.AspNetCore.Authentication.AuthenticationProperties! properties) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Forbid(Microsoft.AspNetCore.Authentication.AuthenticationProperties! properties, params string![]! authenticationSchemes) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Forbid(params string![]! authenticationSchemes) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Json(object? data, System.Text.Json.JsonSerializerOptions? options = null, string? contentType = null, int? statusCode = null) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.LocalRedirect(string! localUrl) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.LocalRedirectPermanent(string! localUrl) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.LocalRedirectPermanentPreserveMethod(string! localUrl) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.LocalRedirectPreserveMethod(string! localUrl) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.NoContent() -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.NotFound() -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.NotFound(object? value) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Ok() -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Ok(object? value) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.PhysicalFile(string! physicalPath, string! contentType) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.PhysicalFile(string! physicalPath, string! contentType, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.PhysicalFile(string! physicalPath, string! contentType, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag, bool enableRangeProcessing) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.PhysicalFile(string! physicalPath, string! contentType, bool enableRangeProcessing) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.PhysicalFile(string! physicalPath, string! contentType, string? fileDownloadName) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.PhysicalFile(string! physicalPath, string! contentType, string? fileDownloadName, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.PhysicalFile(string! physicalPath, string! contentType, string? fileDownloadName, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag, bool enableRangeProcessing) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.PhysicalFile(string! physicalPath, string! contentType, string? fileDownloadName, bool enableRangeProcessing) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Redirect(string! url) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.RedirectPermanent(string! url) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.RedirectPermanentPreserveMethod(string! url) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.RedirectPreserveMethod(string! url) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.RedirectToRoute(object? routeValues) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.RedirectToRoute(string? routeName) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.RedirectToRoute(string? routeName, object? routeValues) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.RedirectToRoute(string? routeName, object? routeValues, string? fragment) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.RedirectToRoute(string? routeName, string? fragment) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.RedirectToRoutePermanent(object? routeValues) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.RedirectToRoutePermanent(string? routeName) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.RedirectToRoutePermanent(string? routeName, object? routeValues) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.RedirectToRoutePermanent(string? routeName, object? routeValues, string? fragment) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.RedirectToRoutePermanent(string? routeName, string? fragment) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.RedirectToRoutePermanentPreserveMethod(string? routeName = null, object? routeValues = null, string? fragment = null) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.RedirectToRoutePreserveMethod(string? routeName = null, object? routeValues = null, string? fragment = null) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.SignIn(System.Security.Claims.ClaimsPrincipal! principal) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.SignIn(System.Security.Claims.ClaimsPrincipal! principal, Microsoft.AspNetCore.Authentication.AuthenticationProperties! properties) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.SignIn(System.Security.Claims.ClaimsPrincipal! principal, Microsoft.AspNetCore.Authentication.AuthenticationProperties! properties, string! authenticationScheme) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.SignIn(System.Security.Claims.ClaimsPrincipal! principal, string! authenticationScheme) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.SignOut() -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.SignOut(Microsoft.AspNetCore.Authentication.AuthenticationProperties! properties) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.SignOut(Microsoft.AspNetCore.Authentication.AuthenticationProperties! properties, params string![]! authenticationSchemes) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.SignOut(params string![]! authenticationSchemes) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.StatusCode(int statusCode) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Unauthorized() -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.UnprocessableEntity() -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.UnprocessableEntity(object? error) -> Microsoft.AspNetCore.Http.IResult! diff --git a/src/Http/Http.Results/src/RedirectResult.cs b/src/Http/Http.Results/src/RedirectResult.cs new file mode 100644 index 000000000000..8624683290b6 --- /dev/null +++ b/src/Http/Http.Results/src/RedirectResult.cs @@ -0,0 +1,107 @@ +// Copyright (c) .NET Foundation. 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.Threading.Tasks; +using Microsoft.AspNetCore.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Http.Result +{ + internal sealed partial class RedirectResult : IResult + { + /// + /// Initializes a new instance of the class with the values + /// provided. + /// + /// The local URL to redirect to. + public RedirectResult(string url) + : this(url, permanent: false) + { + } + + /// + /// Initializes a new instance of the class with the values + /// provided. + /// + /// The URL to redirect to. + /// Specifies whether the redirect should be permanent (301) or temporary (302). + public RedirectResult(string url, bool permanent) + : this(url, permanent, preserveMethod: false) + { + } + + /// + /// Initializes a new instance of the class with the values + /// provided. + /// + /// The URL to redirect to. + /// Specifies whether the redirect should be permanent (301) or temporary (302). + /// If set to true, make the temporary redirect (307) or permanent redirect (308) preserve the initial request method. + public RedirectResult(string url, bool permanent, bool preserveMethod) + { + if (url == null) + { + throw new ArgumentNullException(nameof(url)); + } + + if (string.IsNullOrEmpty(url)) + { + throw new ArgumentException("Argument cannot be null or empty", nameof(url)); + } + + Permanent = permanent; + PreserveMethod = preserveMethod; + Url = url; + } + + /// + /// Gets or sets the value that specifies that the redirect should be permanent if true or temporary if false. + /// + public bool Permanent { get; } + + /// + /// Gets or sets an indication that the redirect preserves the initial request method. + /// + public bool PreserveMethod { get; } + + /// + /// Gets or sets the URL to redirect to. + /// + public string Url { get; } + + /// + public Task ExecuteAsync(HttpContext httpContext) + { + var logger = httpContext.RequestServices.GetRequiredService>(); + + // IsLocalUrl is called to handle URLs starting with '~/'. + var destinationUrl = SharedUrlHelper.IsLocalUrl(Url) ? SharedUrlHelper.Content(httpContext, Url) : Url; + + Log.RedirectResultExecuting(logger, destinationUrl); + + if (PreserveMethod) + { + httpContext.Response.StatusCode = Permanent + ? StatusCodes.Status308PermanentRedirect + : StatusCodes.Status307TemporaryRedirect; + httpContext.Response.Headers.Location = destinationUrl; + } + else + { + httpContext.Response.Redirect(destinationUrl, Permanent); + } + + return Task.CompletedTask; + } + + private static partial class Log + { + [LoggerMessage(1, LogLevel.Information, + "Executing RedirectResult, redirecting to {Destination}.", + EventName = "RedirectResultExecuting")] + public static partial void RedirectResultExecuting(ILogger logger, string destination); + } + } +} diff --git a/src/Http/Http.Results/src/RedirectToRouteResult.cs b/src/Http/Http.Results/src/RedirectToRouteResult.cs new file mode 100644 index 000000000000..11711b1d8cb0 --- /dev/null +++ b/src/Http/Http.Results/src/RedirectToRouteResult.cs @@ -0,0 +1,194 @@ +// Copyright (c) .NET Foundation. 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.Threading.Tasks; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Http.Result +{ + /// + /// An that returns a Found (302), Moved Permanently (301), Temporary Redirect (307), + /// or Permanent Redirect (308) response with a Location header. + /// Targets a registered route. + /// + internal sealed partial class RedirectToRouteResult : IResult + { + /// + /// Initializes a new instance of the with the values + /// provided. + /// + /// The parameters for the route. + public RedirectToRouteResult(object? routeValues) + : this(routeName: null, routeValues: routeValues) + { + } + + /// + /// Initializes a new instance of the with the values + /// provided. + /// + /// The name of the route. + /// The parameters for the route. + public RedirectToRouteResult( + string? routeName, + object? routeValues) + : this(routeName, routeValues, permanent: false) + { + } + + /// + /// Initializes a new instance of the with the values + /// provided. + /// + /// The name of the route. + /// The parameters for the route. + /// If set to true, makes the redirect permanent (301). Otherwise a temporary redirect is used (302). + public RedirectToRouteResult( + string? routeName, + object? routeValues, + bool permanent) + : this(routeName, routeValues, permanent, fragment: null) + { + } + + /// + /// Initializes a new instance of the with the values + /// provided. + /// + /// The name of the route. + /// The parameters for the route. + /// If set to true, makes the redirect permanent (301). Otherwise a temporary redirect is used (302). + /// If set to true, make the temporary redirect (307) or permanent redirect (308) preserve the initial request method. + public RedirectToRouteResult( + string? routeName, + object? routeValues, + bool permanent, + bool preserveMethod) + : this(routeName, routeValues, permanent, preserveMethod, fragment: null) + { + } + + /// + /// Initializes a new instance of the with the values + /// provided. + /// + /// The name of the route. + /// The parameters for the route. + /// The fragment to add to the URL. + public RedirectToRouteResult( + string? routeName, + object? routeValues, + string? fragment) + : this(routeName, routeValues, permanent: false, fragment: fragment) + { + } + + /// + /// Initializes a new instance of the with the values + /// provided. + /// + /// The name of the route. + /// The parameters for the route. + /// If set to true, makes the redirect permanent (301). Otherwise a temporary redirect is used (302). + /// The fragment to add to the URL. + public RedirectToRouteResult( + string? routeName, + object? routeValues, + bool permanent, + string? fragment) + : this(routeName, routeValues, permanent, preserveMethod: false, fragment: fragment) + { + } + + /// + /// Initializes a new instance of the with the values + /// provided. + /// + /// The name of the route. + /// The parameters for the route. + /// If set to true, makes the redirect permanent (301). Otherwise a temporary redirect is used (302). + /// If set to true, make the temporary redirect (307) or permanent redirect (308) preserve the initial request method. + /// The fragment to add to the URL. + public RedirectToRouteResult( + string? routeName, + object? routeValues, + bool permanent, + bool preserveMethod, + string? fragment) + { + RouteName = routeName; + RouteValues = routeValues == null ? null : new RouteValueDictionary(routeValues); + PreserveMethod = preserveMethod; + Permanent = permanent; + Fragment = fragment; + } + + /// + /// Gets or sets the name of the route to use for generating the URL. + /// + public string? RouteName { get; } + + /// + /// Gets or sets the route data to use for generating the URL. + /// + public RouteValueDictionary? RouteValues { get; } + + /// + /// Gets or sets an indication that the redirect is permanent. + /// + public bool Permanent { get; } + + /// + /// Gets or sets an indication that the redirect preserves the initial request method. + /// + public bool PreserveMethod { get; } + + /// + /// Gets or sets the fragment to add to the URL. + /// + public string? Fragment { get; } + + /// + public Task ExecuteAsync(HttpContext httpContext) + { + var linkGenerator = httpContext.RequestServices.GetRequiredService(); + + var destinationUrl = linkGenerator.GetUriByRouteValues( + httpContext, + RouteName, + RouteValues, + fragment: Fragment == null ? FragmentString.Empty : new FragmentString("#" + Fragment)); + if (string.IsNullOrEmpty(destinationUrl)) + { + throw new InvalidOperationException("No route matches the supplied values."); + } + + var logger = httpContext.RequestServices.GetRequiredService>(); + Log.RedirectToRouteResultExecuting(logger, destinationUrl, RouteName); + + if (PreserveMethod) + { + httpContext.Response.StatusCode = Permanent ? + StatusCodes.Status308PermanentRedirect : StatusCodes.Status307TemporaryRedirect; + httpContext.Response.Headers.Location = destinationUrl; + } + else + { + httpContext.Response.Redirect(destinationUrl, Permanent); + } + + return Task.CompletedTask; + } + + private static partial class Log + { + [LoggerMessage(1, LogLevel.Information, + "Executing RedirectToRouteResult, redirecting to {Destination} from route {RouteName}.", + EventName = "RedirectToRouteResultExecuting")] + public static partial void RedirectToRouteResultExecuting(ILogger logger, string destination, string? routeName); + } + } +} diff --git a/src/Http/Http.Results/src/Results.cs b/src/Http/Http.Results/src/Results.cs new file mode 100644 index 000000000000..5b4162c924ea --- /dev/null +++ b/src/Http/Http.Results/src/Results.cs @@ -0,0 +1,1527 @@ +// Copyright (c) .NET Foundation. 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 System.Security.Claims; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http.Result; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Http +{ + /// + /// A factory for . + /// + public static class Results + { + #region SignIn / SignOut / Challenge + + /// + /// Creates an that on execution invokes . + /// + /// The behavior of this method depends on the in use. + /// and + /// are among likely status results. + /// + /// + /// The created for the response. + public static IResult Challenge() + => new ChallengeResult(); + + /// + /// Creates an that on execution invokes . + /// + /// The behavior of this method depends on the in use. + /// and + /// are among likely status results. + /// + /// + /// The authentication schemes to challenge. + /// The created for the response. + public static IResult Challenge(params string[] authenticationSchemes) + => new ChallengeResult { AuthenticationSchemes = authenticationSchemes }; + + /// + /// Creates an that on execution invokes . + /// + /// The behavior of this method depends on the in use. + /// and + /// are among likely status results. + /// + /// + /// used to perform the authentication + /// challenge. + /// The created for the response. + public static IResult Challenge(AuthenticationProperties properties) + => new ChallengeResult { Properties = properties }; + + /// + /// Creates an that on execution invokes . + /// + /// The behavior of this method depends on the in use. + /// and + /// are among likely status results. + /// + /// + /// used to perform the authentication + /// challenge. + /// The authentication schemes to challenge. + /// The created for the response. + public static IResult Challenge( + AuthenticationProperties properties, + params string[] authenticationSchemes) + => new ChallengeResult { AuthenticationSchemes = authenticationSchemes, Properties = properties }; + + /// + /// Creates an that on execution invokes . + /// + /// The containing the user claims. + /// The created for the response. + public static IResult SignIn(ClaimsPrincipal principal) + => new SignInResult(principal); + + /// + /// Creates an that on execution invokes . + /// + /// The containing the user claims. + /// The authentication scheme to use for the sign-in operation. + /// The created for the response. + public static IResult SignIn(ClaimsPrincipal principal, string authenticationScheme) + => new SignInResult(authenticationScheme, principal); + + /// + /// Creates an that on execution invokes . + /// + /// The containing the user claims. + /// used to perform the sign-in operation. + /// The created for the response. + public static IResult SignIn( + ClaimsPrincipal principal, + AuthenticationProperties properties) + => new SignInResult(principal, properties); + + /// + /// Creates an that on execution invokes . + /// + /// The containing the user claims. + /// used to perform the sign-in operation. + /// The authentication scheme to use for the sign-in operation. + /// The created for the response. + public static IResult SignIn( + ClaimsPrincipal principal, + AuthenticationProperties properties, + string authenticationScheme) + => new SignInResult(authenticationScheme, principal, properties); + + /// + /// Creates an that on execution invokes . + /// + /// The created for the response. + public static IResult SignOut() + => new SignOutResult(); + + /// + /// Creates an that on execution invokes . + /// + /// used to perform the sign-out operation. + /// The created for the response. + public static IResult SignOut(AuthenticationProperties properties) + => new SignOutResult(properties); + + /// + /// Creates an that on execution invokes . + /// + /// The authentication schemes to use for the sign-out operation. + /// The created for the response. + public static IResult SignOut(params string[] authenticationSchemes) + => new SignOutResult(authenticationSchemes); + + /// + /// Creates an that on execution invokes . + /// + /// used to perform the sign-out operation. + /// The authentication scheme to use for the sign-out operation. + /// The created for the response. + public static IResult SignOut(AuthenticationProperties properties, params string[] authenticationSchemes) + => new SignOutResult(authenticationSchemes, properties); + #endregion + + #region ContentResult + /// + /// Writes the string to the HTTP response. + /// + /// The content to write to the response. + /// The created object for the response. + public static IResult Content(string content) + => Content(content, (MediaTypeHeaderValue?)null); + + /// + /// Writes the string to the HTTP response. + /// + /// The content to write to the response. + /// The content type (MIME type). + /// The created object for the response. + public static IResult Content(string content, string contentType) + => Content(content, MediaTypeHeaderValue.Parse(contentType)); + + /// + /// Writes the string to the HTTP response. + /// + /// The content to write to the response. + /// The content type (MIME type). + /// The content encoding. + /// The created object for the response. + /// + /// If encoding is provided by both the 'charset' and the parameters, then + /// the parameter is chosen as the final encoding. + /// + public static IResult Content(string content, string contentType, Encoding contentEncoding) + { + var mediaTypeHeaderValue = MediaTypeHeaderValue.Parse(contentType); + mediaTypeHeaderValue.Encoding = contentEncoding ?? mediaTypeHeaderValue.Encoding; + return Content(content, mediaTypeHeaderValue); + } + + /// + /// Writes the string to the HTTP response. + /// + /// The content to write to the response. + /// The content type (MIME type). + /// The created object for the response. + public static IResult Content(string content, MediaTypeHeaderValue? contentType) + { + return new ContentResult + { + Content = content, + ContentType = contentType?.ToString() + }; + } + #endregion + + #region ForbidResult + /// + /// Creates a that on execution invokes . + /// + /// By default, executing this result returns a . Some authentication schemes, such as cookies, + /// will convert to a redirect to show a login page. + /// + /// + /// The created for the response. + public static IResult Forbid() + => new ForbidResult(); + + /// + /// Creates a that on execution invokes . + /// + /// By default, executing this result returns a . Some authentication schemes, such as cookies, + /// will convert to a redirect to show a login page. + /// + /// + /// The authentication schemes to challenge. + /// The created for the response. + /// + /// Some authentication schemes, such as cookies, will convert to + /// a redirect to show a login page. + /// + public static IResult Forbid(params string[] authenticationSchemes) + => new ForbidResult { AuthenticationSchemes = authenticationSchemes }; + + /// + /// Creates a that on execution invokes . + /// + /// By default, executing this result returns a . Some authentication schemes, such as cookies, + /// will convert to a redirect to show a login page. + /// + /// + /// used to perform the authentication + /// challenge. + /// The created for the response. + /// + /// Some authentication schemes, such as cookies, will convert to + /// a redirect to show a login page. + /// + public static IResult Forbid(AuthenticationProperties properties) + => new ForbidResult { Properties = properties }; + + /// + /// Creates a that on execution invokes . + /// + /// By default, executing this result returns a . Some authentication schemes, such as cookies, + /// will convert to a redirect to show a login page. + /// + /// + /// used to perform the authentication + /// challenge. + /// The authentication schemes to challenge. + /// The created for the response. + /// + /// Some authentication schemes, such as cookies, will convert to + /// a redirect to show a login page. + /// + public static IResult Forbid(AuthenticationProperties properties, params string[] authenticationSchemes) + => new ForbidResult { Properties = properties, AuthenticationSchemes = authenticationSchemes, }; + #endregion + + /// + /// Creates a that serializes the specified object to JSON. + /// + /// The object to write as JSON. + /// The serializer options use when serializing the value. + /// The content-type to set on the response. + /// The status code to set on the response. + /// The created that serializes the specified + /// as JSON format for the response. + /// Callers should cache an instance of serializer settings to avoid + /// recreating cached data with each call. + public static IResult Json(object? data, JsonSerializerOptions? options = null, string? contentType = null, int? statusCode = null) + { + return new JsonResult + { + Value = data, + JsonSerializerOptions = options, + ContentType = contentType, + StatusCode = statusCode, + }; + } + + #region FileContentResult + /// + /// Returns a file with the specified as content (), + /// and the specified as the Content-Type. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The file contents. + /// The Content-Type of the file. + /// The created for the response. + public static IResult File(byte[] fileContents, string contentType) + => File(fileContents, contentType, fileDownloadName: null); + + /// + /// Returns a file with the specified as content (), + /// and the specified as the Content-Type. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The file contents. + /// The Content-Type of the file. + /// Set to true to enable range requests processing. + /// The created for the response. + public static IResult File(byte[] fileContents, string contentType, bool enableRangeProcessing) + => File(fileContents, contentType, fileDownloadName: null, enableRangeProcessing: enableRangeProcessing); + + /// + /// Returns a file with the specified as content (), the + /// specified as the Content-Type and the specified as the suggested file name. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The file contents. + /// The Content-Type of the file. + /// The suggested file name. + /// The created for the response. + public static IResult File(byte[] fileContents, string contentType, string? fileDownloadName) + => new FileContentResult(fileContents, contentType) { FileDownloadName = fileDownloadName }; + + /// + /// Returns a file with the specified as content (), the + /// specified as the Content-Type and the specified as the suggested file name. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The file contents. + /// The Content-Type of the file. + /// The suggested file name. + /// Set to true to enable range requests processing. + /// The created for the response. + public static IResult File(byte[] fileContents, string contentType, string? fileDownloadName, bool enableRangeProcessing) + => new FileContentResult(fileContents, contentType) + { + FileDownloadName = fileDownloadName, + EnableRangeProcessing = enableRangeProcessing, + }; + + /// + /// Returns a file with the specified as content (), + /// and the specified as the Content-Type. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The file contents. + /// The Content-Type of the file. + /// The of when the file was last modified. + /// The associated with the file. + /// The created for the response. + public static IResult File(byte[] fileContents, string contentType, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag) + { + return new FileContentResult(fileContents, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + }; + } + + /// + /// Returns a file with the specified as content (), + /// and the specified as the Content-Type. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The file contents. + /// The Content-Type of the file. + /// The of when the file was last modified. + /// The associated with the file. + /// Set to true to enable range requests processing. + /// The created for the response. + public static IResult File(byte[] fileContents, string contentType, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag, bool enableRangeProcessing) + { + return new FileContentResult(fileContents, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + EnableRangeProcessing = enableRangeProcessing, + }; + } + + /// + /// Returns a file with the specified as content (), the + /// specified as the Content-Type, and the specified as the suggested file name. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The file contents. + /// The Content-Type of the file. + /// The suggested file name. + /// The of when the file was last modified. + /// The associated with the file. + /// The created for the response. + public static IResult File(byte[] fileContents, string contentType, string? fileDownloadName, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag) + { + return new FileContentResult(fileContents, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + FileDownloadName = fileDownloadName, + }; + } + + /// + /// Returns a file with the specified as content (), the + /// specified as the Content-Type, and the specified as the suggested file name. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The file contents. + /// The Content-Type of the file. + /// The suggested file name. + /// The of when the file was last modified. + /// The associated with the file. + /// Set to true to enable range requests processing. + /// The created for the response. + public static IResult File(byte[] fileContents, string contentType, string? fileDownloadName, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag, bool enableRangeProcessing) + { + return new FileContentResult(fileContents, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + FileDownloadName = fileDownloadName, + EnableRangeProcessing = enableRangeProcessing, + }; + } + #endregion + + #region FileStreamResult + /// + /// Returns a file in the specified (), with the + /// specified as the Content-Type. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The with the contents of the file. + /// The Content-Type of the file. + /// The created for the response. + /// + /// The parameter is disposed after the response is sent. + /// + public static IResult File(Stream fileStream, string contentType) + => File(fileStream, contentType, fileDownloadName: null); + + /// + /// Returns a file in the specified (), with the + /// specified as the Content-Type. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The with the contents of the file. + /// The Content-Type of the file. + /// Set to true to enable range requests processing. + /// The created for the response. + /// + /// The parameter is disposed after the response is sent. + /// + public static IResult File(Stream fileStream, string contentType, bool enableRangeProcessing) + => File(fileStream, contentType, fileDownloadName: null, enableRangeProcessing: enableRangeProcessing); + + /// + /// Returns a file in the specified () with the + /// specified as the Content-Type and the + /// specified as the suggested file name. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The with the contents of the file. + /// The Content-Type of the file. + /// The suggested file name. + /// The created for the response. + /// + /// The parameter is disposed after the response is sent. + /// + public static IResult File(Stream fileStream, string contentType, string? fileDownloadName) + => new FileStreamResult(fileStream, contentType) { FileDownloadName = fileDownloadName }; + + /// + /// Returns a file in the specified () with the + /// specified as the Content-Type and the + /// specified as the suggested file name. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The with the contents of the file. + /// The Content-Type of the file. + /// The suggested file name. + /// Set to true to enable range requests processing. + /// The created for the response. + /// + /// The parameter is disposed after the response is sent. + /// + public static IResult File(Stream fileStream, string contentType, string? fileDownloadName, bool enableRangeProcessing) + => new FileStreamResult(fileStream, contentType) + { + FileDownloadName = fileDownloadName, + EnableRangeProcessing = enableRangeProcessing, + }; + + /// + /// Returns a file in the specified (), + /// and the specified as the Content-Type. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The with the contents of the file. + /// The Content-Type of the file. + /// The of when the file was last modified. + /// The associated with the file. + /// The created for the response. + /// + /// The parameter is disposed after the response is sent. + /// + public static IResult File(Stream fileStream, string contentType, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag) + { + return new FileStreamResult(fileStream, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + }; + } + + /// + /// Returns a file in the specified (), + /// and the specified as the Content-Type. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The with the contents of the file. + /// The Content-Type of the file. + /// The of when the file was last modified. + /// The associated with the file. + /// Set to true to enable range requests processing. + /// The created for the response. + /// + /// The parameter is disposed after the response is sent. + /// + public static IResult File(Stream fileStream, string contentType, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag, bool enableRangeProcessing) + { + return new FileStreamResult(fileStream, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + EnableRangeProcessing = enableRangeProcessing, + }; + } + + /// + /// Returns a file in the specified (), the + /// specified as the Content-Type, and the specified as the suggested file name. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The with the contents of the file. + /// The Content-Type of the file. + /// The suggested file name. + /// The of when the file was last modified. + /// The associated with the file. + /// The created for the response. + /// + /// The parameter is disposed after the response is sent. + /// + public static IResult File(Stream fileStream, string contentType, string? fileDownloadName, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag) + { + return new FileStreamResult(fileStream, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + FileDownloadName = fileDownloadName, + }; + } + + /// + /// Returns a file in the specified (), the + /// specified as the Content-Type, and the specified as the suggested file name. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The with the contents of the file. + /// The Content-Type of the file. + /// The suggested file name. + /// The of when the file was last modified. + /// The associated with the file. + /// Set to true to enable range requests processing. + /// The created for the response. + /// + /// The parameter is disposed after the response is sent. + /// + public static IResult File(Stream fileStream, string contentType, string? fileDownloadName, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag, bool enableRangeProcessing) + { + return new FileStreamResult(fileStream, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + FileDownloadName = fileDownloadName, + EnableRangeProcessing = enableRangeProcessing, + }; + } + #endregion + + #region PhysicalFileResult + /// + /// Returns the file specified by () with the + /// specified as the Content-Type. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The path to the file. The path must be an absolute path. + /// The Content-Type of the file. + /// The created for the response. + public static IResult PhysicalFile(string physicalPath, string contentType) + => PhysicalFile(physicalPath, contentType, fileDownloadName: null); + + /// + /// Returns the file specified by () with the + /// specified as the Content-Type. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The path to the file. The path must be an absolute path. + /// The Content-Type of the file. + /// Set to true to enable range requests processing. + /// The created for the response. + public static IResult PhysicalFile(string physicalPath, string contentType, bool enableRangeProcessing) + => PhysicalFile(physicalPath, contentType, fileDownloadName: null, enableRangeProcessing: enableRangeProcessing); + + /// + /// Returns the file specified by () with the + /// specified as the Content-Type and the + /// specified as the suggested file name. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The path to the file. The path must be an absolute path. + /// The Content-Type of the file. + /// The suggested file name. + /// The created for the response. + public static IResult PhysicalFile( + string physicalPath, + string contentType, + string? fileDownloadName) + => new PhysicalFileResult(physicalPath, contentType) { FileDownloadName = fileDownloadName }; + + /// + /// Returns the file specified by () with the + /// specified as the Content-Type and the + /// specified as the suggested file name. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The path to the file. The path must be an absolute path. + /// The Content-Type of the file. + /// The suggested file name. + /// Set to true to enable range requests processing. + /// The created for the response. + public static IResult PhysicalFile( + string physicalPath, + string contentType, + string? fileDownloadName, + bool enableRangeProcessing) + => new PhysicalFileResult(physicalPath, contentType) + { + FileDownloadName = fileDownloadName, + EnableRangeProcessing = enableRangeProcessing, + }; + + /// + /// Returns the file specified by (), and + /// the specified as the Content-Type. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The path to the file. The path must be an absolute path. + /// The Content-Type of the file. + /// The of when the file was last modified. + /// The associated with the file. + /// The created for the response. + public static IResult PhysicalFile(string physicalPath, string contentType, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag) + { + return new PhysicalFileResult(physicalPath, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + }; + } + + /// + /// Returns the file specified by (), and + /// the specified as the Content-Type. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The path to the file. The path must be an absolute path. + /// The Content-Type of the file. + /// The of when the file was last modified. + /// The associated with the file. + /// Set to true to enable range requests processing. + /// The created for the response. + public static IResult PhysicalFile(string physicalPath, string contentType, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag, bool enableRangeProcessing) + { + return new PhysicalFileResult(physicalPath, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + EnableRangeProcessing = enableRangeProcessing, + }; + } + + /// + /// Returns the file specified by (), the + /// specified as the Content-Type, and the specified as the suggested file name. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The path to the file. The path must be an absolute path. + /// The Content-Type of the file. + /// The suggested file name. + /// The of when the file was last modified. + /// The associated with the file. + /// The created for the response. + public static IResult PhysicalFile(string physicalPath, string contentType, string? fileDownloadName, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag) + { + return new PhysicalFileResult(physicalPath, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + FileDownloadName = fileDownloadName, + }; + } + + /// + /// Returns the file specified by (), the + /// specified as the Content-Type, and the specified as the suggested file name. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The path to the file. The path must be an absolute path. + /// The Content-Type of the file. + /// The suggested file name. + /// The of when the file was last modified. + /// The associated with the file. + /// Set to true to enable range requests processing. + /// The created for the response. + public static IResult PhysicalFile(string physicalPath, string contentType, string? fileDownloadName, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag, bool enableRangeProcessing) + { + return new PhysicalFileResult(physicalPath, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + FileDownloadName = fileDownloadName, + EnableRangeProcessing = enableRangeProcessing, + }; + } + #endregion + + #region VirtualFileResult + /// + /// Returns the file specified by () with the + /// specified as the Content-Type. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The virtual path of the file to be returned. + /// The Content-Type of the file. + /// The created for the response. + public static IResult File(string virtualPath, string contentType) + => File(virtualPath, contentType, fileDownloadName: null); + + /// + /// Returns the file specified by () with the + /// specified as the Content-Type. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The virtual path of the file to be returned. + /// The Content-Type of the file. + /// Set to true to enable range requests processing. + /// The created for the response. + public static IResult File(string virtualPath, string contentType, bool enableRangeProcessing) + => File(virtualPath, contentType, fileDownloadName: null, enableRangeProcessing: enableRangeProcessing); + + /// + /// Returns the file specified by () with the + /// specified as the Content-Type and the + /// specified as the suggested file name. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The virtual path of the file to be returned. + /// The Content-Type of the file. + /// The suggested file name. + /// The created for the response. + public static IResult File(string virtualPath, string contentType, string? fileDownloadName) + => new VirtualFileResult(virtualPath, contentType) { FileDownloadName = fileDownloadName }; + + /// + /// Returns the file specified by () with the + /// specified as the Content-Type and the + /// specified as the suggested file name. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The virtual path of the file to be returned. + /// The Content-Type of the file. + /// The suggested file name. + /// Set to true to enable range requests processing. + /// The created for the response. + public static IResult File(string virtualPath, string contentType, string? fileDownloadName, bool enableRangeProcessing) + => new VirtualFileResult(virtualPath, contentType) + { + FileDownloadName = fileDownloadName, + EnableRangeProcessing = enableRangeProcessing, + }; + + /// + /// Returns the file specified by (), and the + /// specified as the Content-Type. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The virtual path of the file to be returned. + /// The Content-Type of the file. + /// The of when the file was last modified. + /// The associated with the file. + /// The created for the response. + public static IResult File(string virtualPath, string contentType, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag) + { + return new VirtualFileResult(virtualPath, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + }; + } + + /// + /// Returns the file specified by (), and the + /// specified as the Content-Type. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The virtual path of the file to be returned. + /// The Content-Type of the file. + /// The of when the file was last modified. + /// The associated with the file. + /// Set to true to enable range requests processing. + /// The created for the response. + public static IResult File(string virtualPath, string contentType, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag, bool enableRangeProcessing) + { + return new VirtualFileResult(virtualPath, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + EnableRangeProcessing = enableRangeProcessing, + }; + } + + /// + /// Returns the file specified by (), the + /// specified as the Content-Type, and the specified as the suggested file name. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The virtual path of the file to be returned. + /// The Content-Type of the file. + /// The suggested file name. + /// The of when the file was last modified. + /// The associated with the file. + /// The created for the response. + public static IResult File(string virtualPath, string contentType, string? fileDownloadName, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag) + { + return new VirtualFileResult(virtualPath, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + FileDownloadName = fileDownloadName, + }; + } + + /// + /// Returns the file specified by (), the + /// specified as the Content-Type, and the specified as the suggested file name. + /// + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// + /// The virtual path of the file to be returned. + /// The Content-Type of the file. + /// The suggested file name. + /// The of when the file was last modified. + /// The associated with the file. + /// Set to true to enable range requests processing. + /// The created for the response. + public static IResult File(string virtualPath, string contentType, string? fileDownloadName, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag, bool enableRangeProcessing) + { + return new VirtualFileResult(virtualPath, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + FileDownloadName = fileDownloadName, + EnableRangeProcessing = enableRangeProcessing, + }; + } + #endregion + + #region RedirectResult variants + /// + /// Redirects () + /// to the specified . + /// + /// The URL to redirect to. + /// The created for the response. + public static IResult Redirect(string url) + { + if (string.IsNullOrEmpty(url)) + { + throw new ArgumentException("Argument cannot be null or empty.", nameof(url)); + } + + return new RedirectResult(url); + } + + /// + /// Redirects () + /// to the specified . + /// + /// The URL to redirect to. + /// The created for the response. + public static IResult RedirectPermanent(string url) + { + if (string.IsNullOrEmpty(url)) + { + throw new ArgumentException("Argument cannot be null or empty.", nameof(url)); + } + + return new RedirectResult(url, permanent: true); + } + + /// + /// Redirects () + /// to the specified preserving the HTTP method. + /// + /// The URL to redirect to. + /// The created for the response. + public static IResult RedirectPreserveMethod(string url) + { + if (string.IsNullOrEmpty(url)) + { + throw new ArgumentException("Argument cannot be null or empty.", nameof(url)); + } + + return new RedirectResult(url: url, permanent: false, preserveMethod: true); + } + + /// + /// Redirects () + /// to the specified preserving the HTTP method. + /// + /// The URL to redirect to. + /// The created for the response. + public static IResult RedirectPermanentPreserveMethod(string url) + { + if (string.IsNullOrEmpty(url)) + { + throw new ArgumentException("Argument cannot be null or empty.", nameof(url)); + } + + return new RedirectResult(url: url, permanent: true, preserveMethod: true); + } + + /// + /// Redirects () + /// to the specified . + /// + /// The local URL to redirect to. + /// The created for the response. + public static IResult LocalRedirect(string localUrl) + { + if (string.IsNullOrEmpty(localUrl)) + { + throw new ArgumentException("Argument cannot be null or empty.", nameof(localUrl)); + } + + return new LocalRedirectResult(localUrl); + } + + /// + /// Redirects () + /// to the specified . + /// + /// The local URL to redirect to. + /// The created for the response. + public static IResult LocalRedirectPermanent(string localUrl) + { + if (string.IsNullOrEmpty(localUrl)) + { + throw new ArgumentException("Argument cannot be null or empty.", nameof(localUrl)); + } + + return new LocalRedirectResult(localUrl, permanent: true); + } + + /// + /// Redirects () + /// to the specified preserving the HTTP method. + /// + /// The local URL to redirect to. + /// The created for the response. + public static IResult LocalRedirectPreserveMethod(string localUrl) + { + if (string.IsNullOrEmpty(localUrl)) + { + throw new ArgumentException("Argument cannot be null or empty.", nameof(localUrl)); + } + + return new LocalRedirectResult(localUrl: localUrl, permanent: false, preserveMethod: true); + } + + /// + /// Redirects () + /// to the specified preserving the HTTP method. + /// + /// The local URL to redirect to. + /// The created for the response. + public static IResult LocalRedirectPermanentPreserveMethod(string localUrl) + { + if (string.IsNullOrEmpty(localUrl)) + { + throw new ArgumentException("Argument cannot be null or empty.", nameof(localUrl)); + } + + return new LocalRedirectResult(localUrl: localUrl, permanent: true, preserveMethod: true); + } + + /// + /// Redirects () to the specified route using the specified . + /// + /// The name of the route. + /// The created for the response. + public static IResult RedirectToRoute(string? routeName) + => RedirectToRoute(routeName, routeValues: null); + + /// + /// Redirects () to the specified route using the specified . + /// + /// The parameters for a route. + /// The created for the response. + public static IResult RedirectToRoute(object? routeValues) + => RedirectToRoute(routeName: null, routeValues: routeValues); + + /// + /// Redirects () to the specified route using the specified + /// and . + /// + /// The name of the route. + /// The parameters for a route. + /// The created for the response. + public static IResult RedirectToRoute(string? routeName, object? routeValues) + => RedirectToRoute(routeName, routeValues, fragment: null); + + /// + /// Redirects () to the specified route using the specified + /// and . + /// + /// The name of the route. + /// The fragment to add to the URL. + /// The created for the response. + public static IResult RedirectToRoute(string? routeName, string? fragment) + => RedirectToRoute(routeName, routeValues: null, fragment: fragment); + + /// + /// Redirects () to the specified route using the specified + /// , , and . + /// + /// The name of the route. + /// The parameters for a route. + /// The fragment to add to the URL. + /// The created for the response. + public static IResult RedirectToRoute( + string? routeName, + object? routeValues, + string? fragment) + { + return new RedirectToRouteResult(routeName, routeValues, fragment); + } + + /// + /// Redirects () to the specified route with + /// set to false and + /// set to true, using the specified , , and . + /// + /// The name of the route. + /// The route data to use for generating the URL. + /// The fragment to add to the URL. + /// The created for the response. + public static IResult RedirectToRoutePreserveMethod( + string? routeName = null, + object? routeValues = null, + string? fragment = null) + { + return new RedirectToRouteResult( + routeName: routeName, + routeValues: routeValues, + permanent: false, + preserveMethod: true, + fragment: fragment); + } + + /// + /// Redirects () to the specified route with + /// set to true using the specified . + /// + /// The name of the route. + /// The created for the response. + public static IResult RedirectToRoutePermanent(string? routeName) + => RedirectToRoutePermanent(routeName, routeValues: null); + + /// + /// Redirects () to the specified route with + /// set to true using the specified . + /// + /// The parameters for a route. + /// The created for the response. + public static IResult RedirectToRoutePermanent(object? routeValues) + => RedirectToRoutePermanent(routeName: null, routeValues: routeValues); + + /// + /// Redirects () to the specified route with + /// set to true using the specified + /// and . + /// + /// The name of the route. + /// The parameters for a route. + /// The created for the response. + public static IResult RedirectToRoutePermanent(string? routeName, object? routeValues) + => RedirectToRoutePermanent(routeName, routeValues, fragment: null); + + /// + /// Redirects () to the specified route with + /// set to true using the specified + /// and . + /// + /// The name of the route. + /// The fragment to add to the URL. + /// The created for the response. + public static IResult RedirectToRoutePermanent(string? routeName, string? fragment) + => RedirectToRoutePermanent(routeName, routeValues: null, fragment: fragment); + + /// + /// Redirects () to the specified route with + /// set to true using the specified , + /// , and . + /// + /// The name of the route. + /// The parameters for a route. + /// The fragment to add to the URL. + /// The created for the response. + public static IResult RedirectToRoutePermanent( + string? routeName, + object? routeValues, + string? fragment) + { + return new RedirectToRouteResult(routeName, routeValues, permanent: true, fragment: fragment); + } + + /// + /// Redirects () to the specified route with + /// set to true and + /// set to true, using the specified , , and . + /// + /// The name of the route. + /// The route data to use for generating the URL. + /// The fragment to add to the URL. + /// The created for the response. + public static IResult RedirectToRoutePermanentPreserveMethod( + string? routeName = null, + object? routeValues = null, + string? fragment = null) + { + return new RedirectToRouteResult( + routeName: routeName, + routeValues: routeValues, + permanent: true, + preserveMethod: true, + fragment: fragment); + } + #endregion + + /// + /// Creates a object by specifying a . + /// + /// The status code to set on the response. + /// The created object for the response. + public static IResult StatusCode(int statusCode) + => new StatusCodeResult(statusCode); + + /// + /// Produces a response. + /// + /// The created for the response. + public static IResult NotFound() + => new NotFoundResult(); + + /// + /// Produces a response. + /// + /// The value to be included in the HTTP response body. + /// The created for the response. + public static IResult NotFound(object? value) + => new NotFoundObjectResult(value); + + /// + /// Produces a response. + /// + /// The created for the response. + public static IResult Unauthorized() + => new UnauthorizedResult(); + + /// + /// Produces a response. + /// + /// The created for the response. + public static IResult BadRequest() + => new BadRequestResult(); + + /// + /// Produces a response. + /// + /// An error object to be included in the HTTP response body. + /// The created for the response. + public static IResult BadRequest(object? error) + => new BadRequestObjectResult(error); + + /// + /// Produces a response. + /// + /// The created for the response. + public static IResult Conflict() + => new ConflictResult(); + + /// + /// Produces a response. + /// + /// An error object to be included in the HTTP response body. + /// The created for the response. + public static IResult Conflict(object? error) + => new ConflictObjectResult(error); + + /// + /// Produces a response. + /// + /// The created for the response. + public static IResult NoContent() + => new NoContentResult(); + + /// + /// Produces a response. + /// + /// The created for the response. + public static IResult Ok() + => new OkResult(); + + /// + /// Produces a response. + /// + /// The value to be included in the HTTP response body. + /// The created for the response. + public static IResult Ok(object? value) + => new OkObjectResult(value); + + /// + /// Produces a response. + /// + /// The created for the response. + public static IResult UnprocessableEntity() + => new UnprocessableEntityResult(); + + /// + /// Produces a response. + /// + /// An error object to be included in the HTTP response body. + /// The created for the response. + public static IResult UnprocessableEntity(object? error) + => new UnprocessableEntityObjectResult(error); + + #region CreatedResult + /// + /// Produces a response. + /// + /// The URI at which the content has been created. + /// The value to be included in the HTTP response body. + /// The created for the response. + public static IResult Created(string uri, object? value) + { + if (uri == null) + { + throw new ArgumentNullException(nameof(uri)); + } + + return new CreatedResult(uri, value); + } + + /// + /// Produces a response. + /// + /// The URI at which the content has been created. + /// The value to be included in the HTTP response body. + /// The created for the response. + public static IResult Created(Uri uri, object? value) + { + if (uri == null) + { + throw new ArgumentNullException(nameof(uri)); + } + + return new CreatedResult(uri, value); + } + + /// + /// Produces a response. + /// + /// The name of the route to use for generating the URL. + /// The value to be included in the HTTP response body. + /// The created for the response. + public static IResult CreatedAtRoute(string? routeName, object? value) + => CreatedAtRoute(routeName, routeValues: null, value: value); + + /// + /// Produces a response. + /// + /// The route data to use for generating the URL. + /// The value to be included in the HTTP response body. + /// The created for the response. + public static IResult CreatedAtRoute(object? routeValues, object? value) + => CreatedAtRoute(routeName: null, routeValues: routeValues, value: value); + + /// + /// Produces a response. + /// + /// The name of the route to use for generating the URL. + /// The route data to use for generating the URL. + /// The value to be included in the HTTP response body. + /// The created for the response. + public static IResult CreatedAtRoute(string? routeName, object? routeValues, object? value) + => new CreatedAtRouteResult(routeName, routeValues, value); + + #endregion + + #region AcceptedResult + /// + /// Produces a response. + /// + /// The created for the response. + public static IResult Accepted() + => new AcceptedResult(); + + /// + /// Produces a response. + /// + /// The optional content value to format in the response body. + /// The created for the response. + public static IResult Accepted(object? value) + => new AcceptedResult(location: null, value: value); + + /// + /// Produces a response. + /// + /// The optional URI with the location at which the status of requested content can be monitored. + /// The created for the response. + public static IResult Accepted(Uri uri) + { + if (uri == null) + { + throw new ArgumentNullException(nameof(uri)); + } + + return new AcceptedResult(locationUri: uri, value: null); + } + + /// + /// Produces a response. + /// + /// The optional URI with the location at which the status of requested content can be monitored. + /// The created for the response. + public static IResult Accepted(string? uri) + => new AcceptedResult(location: uri, value: null); + + /// + /// Produces a response. + /// + /// The URI with the location at which the status of requested content can be monitored. + /// The optional content value to format in the response body. + /// The created for the response. + public static IResult Accepted(string? uri, object? value) + => new AcceptedResult(uri, value); + + /// + /// Produces a response. + /// + /// The URI with the location at which the status of requested content can be monitored. + /// The optional content value to format in the response body. + /// The created for the response. + public static IResult Accepted(Uri uri, object? value) + { + if (uri == null) + { + throw new ArgumentNullException(nameof(uri)); + } + + return new AcceptedResult(locationUri: uri, value: value); + } + + /// + /// Produces a response. + /// + /// The route data to use for generating the URL. + /// The created for the response. + public static IResult AcceptedAtRoute(object? routeValues) + => AcceptedAtRoute(routeName: null, routeValues: routeValues, value: null); + + /// + /// Produces a response. + /// + /// The name of the route to use for generating the URL. + /// The created for the response. + public static IResult AcceptedAtRoute(string? routeName) + => AcceptedAtRoute(routeName, routeValues: null, value: null); + + /// + /// Produces a response. + /// + /// The name of the route to use for generating the URL. + ///The route data to use for generating the URL. + /// The created for the response. + public static IResult AcceptedAtRoute(string? routeName, object? routeValues) + => AcceptedAtRoute(routeName, routeValues, value: null); + + /// + /// Produces a response. + /// + /// The route data to use for generating the URL. + /// The optional content value to format in the response body. + /// The created for the response. + public static IResult AcceptedAtRoute(object? routeValues, object? value) + => AcceptedAtRoute(routeName: null, routeValues: routeValues, value: value); + + /// + /// Produces a response. + /// + /// The name of the route to use for generating the URL. + /// The route data to use for generating the URL. + /// The optional content value to format in the response body. + /// The created for the response. + public static IResult AcceptedAtRoute(string? routeName, object? routeValues, object? value) + => new AcceptedAtRouteResult(routeName, routeValues, value); + #endregion + } +} diff --git a/src/Http/Http.Results/src/SignInResult.cs b/src/Http/Http.Results/src/SignInResult.cs new file mode 100644 index 000000000000..3e42c7d35137 --- /dev/null +++ b/src/Http/Http.Results/src/SignInResult.cs @@ -0,0 +1,97 @@ +// Copyright (c) .NET Foundation. 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.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Http.Result +{ + /// + /// An that on execution invokes . + /// + internal sealed partial class SignInResult : IResult + { + /// + /// Initializes a new instance of with the + /// default authentication scheme. + /// + /// The claims principal containing the user claims. + public SignInResult(ClaimsPrincipal principal) + : this(authenticationScheme: null, principal, properties: null) + { + } + + /// + /// Initializes a new instance of with the + /// specified authentication scheme. + /// + /// The authentication scheme to use when signing in the user. + /// The claims principal containing the user claims. + public SignInResult(string? authenticationScheme, ClaimsPrincipal principal) + : this(authenticationScheme, principal, properties: null) + { + } + + /// + /// Initializes a new instance of with the + /// default authentication scheme and . + /// + /// The claims principal containing the user claims. + /// used to perform the sign-in operation. + public SignInResult(ClaimsPrincipal principal, AuthenticationProperties? properties) + : this(authenticationScheme: null, principal, properties) + { + } + + /// + /// Initializes a new instance of with the + /// specified authentication scheme and . + /// + /// The authentication schemes to use when signing in the user. + /// The claims principal containing the user claims. + /// used to perform the sign-in operation. + public SignInResult(string? authenticationScheme, ClaimsPrincipal principal, AuthenticationProperties? properties) + { + Principal = principal ?? throw new ArgumentNullException(nameof(principal)); + AuthenticationScheme = authenticationScheme; + Properties = properties; + } + + /// + /// Gets or sets the authentication scheme that is used to perform the sign-in operation. + /// + public string? AuthenticationScheme { get; set; } + + /// + /// Gets or sets the containing the user claims. + /// + public ClaimsPrincipal Principal { get; set; } + + /// + /// Gets or sets the used to perform the sign-in operation. + /// + public AuthenticationProperties? Properties { get; set; } + + /// + public Task ExecuteAsync(HttpContext httpContext) + { + var logger = httpContext.RequestServices.GetRequiredService>(); + + Log.SignInResultExecuting(logger, AuthenticationScheme, Principal); + + return httpContext.SignInAsync(AuthenticationScheme, Principal, Properties); + } + + private static partial class Log + { + [LoggerMessage(1, LogLevel.Information, + "Executing SignInResult with authentication scheme ({Scheme}) and the following principal: {Principal}.", + EventName = "SignInResultExecuting")] + public static partial void SignInResultExecuting(ILogger logger, string? scheme, ClaimsPrincipal principal); + } + } +} diff --git a/src/Http/Http.Results/src/SignOutResult.cs b/src/Http/Http.Results/src/SignOutResult.cs new file mode 100644 index 000000000000..6601a9eb1ed5 --- /dev/null +++ b/src/Http/Http.Results/src/SignOutResult.cs @@ -0,0 +1,127 @@ +// Copyright (c) .NET Foundation. 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.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Http.Result +{ + /// + /// An that on execution invokes . + /// + internal sealed partial class SignOutResult : IResult + { + /// + /// Initializes a new instance of with the default sign out scheme. + /// + public SignOutResult() + : this(Array.Empty()) + { + } + + /// + /// Initializes a new instance of with the default sign out scheme. + /// specified authentication scheme and . + /// + /// used to perform the sign-out operation. + public SignOutResult(AuthenticationProperties properties) + : this(Array.Empty(), properties) + { + } + + /// + /// Initializes a new instance of with the + /// specified authentication scheme. + /// + /// The authentication scheme to use when signing out the user. + public SignOutResult(string authenticationScheme) + : this(new[] { authenticationScheme }) + { + } + + /// + /// Initializes a new instance of with the + /// specified authentication schemes. + /// + /// The authentication schemes to use when signing out the user. + public SignOutResult(IList authenticationSchemes) + : this(authenticationSchemes, properties: null) + { + } + + /// + /// Initializes a new instance of with the + /// specified authentication scheme and . + /// + /// The authentication schemes to use when signing out the user. + /// used to perform the sign-out operation. + public SignOutResult(string authenticationScheme, AuthenticationProperties? properties) + : this(new[] { authenticationScheme }, properties) + { + } + + /// + /// Initializes a new instance of with the + /// specified authentication schemes and . + /// + /// The authentication scheme to use when signing out the user. + /// used to perform the sign-out operation. + public SignOutResult(IList authenticationSchemes, AuthenticationProperties? properties) + { + AuthenticationSchemes = authenticationSchemes ?? throw new ArgumentNullException(nameof(authenticationSchemes)); + Properties = properties; + } + + /// + /// Gets or sets the authentication schemes that are challenged. + /// + public IList AuthenticationSchemes { get; init; } + + /// + /// Gets or sets the used to perform the sign-out operation. + /// + public AuthenticationProperties? Properties { get; init; } + + /// + public async Task ExecuteAsync(HttpContext httpContext) + { + var logger = httpContext.RequestServices.GetRequiredService>(); + + Log.SignOutResultExecuting(logger, AuthenticationSchemes); + + if (AuthenticationSchemes.Count == 0) + { + await httpContext.SignOutAsync(Properties); + } + else + { + for (var i = 0; i < AuthenticationSchemes.Count; i++) + { + await httpContext.SignOutAsync(AuthenticationSchemes[i], Properties); + } + } + } + + private static partial class Log + { + public static void SignOutResultExecuting(ILogger logger, IList authenticationSchemes) + { + if (logger.IsEnabled(LogLevel.Information)) + { + SignOutResultExecuting(logger, authenticationSchemes.ToArray()); + } + } + + [LoggerMessage(1, LogLevel.Information, + "Executing SignOutResult with authentication schemes ({Schemes}).", + EventName = "SignOutResultExecuting", + SkipEnabledCheck = true)] + private static partial void SignOutResultExecuting(ILogger logger, string[] schemes); + } + } +} diff --git a/src/Http/Http.Results/src/StatusCodeResult.cs b/src/Http/Http.Results/src/StatusCodeResult.cs new file mode 100644 index 000000000000..0c8684928446 --- /dev/null +++ b/src/Http/Http.Results/src/StatusCodeResult.cs @@ -0,0 +1,51 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Http.Result +{ + internal partial class StatusCodeResult : IResult + { + /// + /// Initializes a new instance of the class + /// with the given . + /// + /// The HTTP status code of the response. + public StatusCodeResult(int statusCode) + { + StatusCode = statusCode; + } + + /// + /// Gets the HTTP status code. + /// + public int StatusCode { get; } + + /// + /// Sets the status code on the HTTP response. + /// + /// The for the current request. + /// A task that represents the asynchronous execute operation. + public Task ExecuteAsync(HttpContext httpContext) + { + var factory = httpContext.RequestServices.GetRequiredService(); + var logger = factory.CreateLogger(GetType()); + + Log.StatusCodeResultExecuting(logger, StatusCode); + + httpContext.Response.StatusCode = StatusCode; + return Task.CompletedTask; + } + + private static partial class Log + { + [LoggerMessage(1, LogLevel.Information, + "Executing StatusCodeResult, setting HTTP status code {StatusCode}.", + EventName = "StatusCodeResultExecuting")] + public static partial void StatusCodeResultExecuting(ILogger logger, int statusCode); + } + } +} diff --git a/src/Http/Http.Results/src/UnauthorizedResult.cs b/src/Http/Http.Results/src/UnauthorizedResult.cs new file mode 100644 index 000000000000..ddf3b032e00e --- /dev/null +++ b/src/Http/Http.Results/src/UnauthorizedResult.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Http.Result +{ + internal sealed class UnauthorizedResult : StatusCodeResult + { + public UnauthorizedResult() : base(StatusCodes.Status401Unauthorized) + { + } + } +} diff --git a/src/Http/Http.Results/src/UnprocessableEntityObjectResult.cs b/src/Http/Http.Results/src/UnprocessableEntityObjectResult.cs new file mode 100644 index 000000000000..d188da672a78 --- /dev/null +++ b/src/Http/Http.Results/src/UnprocessableEntityObjectResult.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Http.Result +{ + internal sealed class UnprocessableEntityObjectResult : ObjectResult + { + public UnprocessableEntityObjectResult(object? error) + : base(error, StatusCodes.Status422UnprocessableEntity) + { + } + } +} diff --git a/src/Http/Http.Results/src/UnprocessableEntityResult.cs b/src/Http/Http.Results/src/UnprocessableEntityResult.cs new file mode 100644 index 000000000000..f58c43059e94 --- /dev/null +++ b/src/Http/Http.Results/src/UnprocessableEntityResult.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Http.Result +{ + internal sealed class UnprocessableEntityResult : StatusCodeResult + { + public UnprocessableEntityResult() : base(StatusCodes.Status422UnprocessableEntity) + { + } + } +} diff --git a/src/Http/Http.Results/src/VirtualFileResult.cs b/src/Http/Http.Results/src/VirtualFileResult.cs new file mode 100644 index 000000000000..975026f35298 --- /dev/null +++ b/src/Http/Http.Results/src/VirtualFileResult.cs @@ -0,0 +1,129 @@ +// Copyright (c) .NET Foundation. 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.Diagnostics.CodeAnalysis; +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Http.Result +{ + /// + /// A that on execution writes the file specified using a virtual path to the response + /// using mechanisms provided by the host. + /// + internal sealed class VirtualFileResult : FileResult, IResult + { + private string _fileName; + + /// + /// Creates a new instance with the provided + /// and the provided . + /// + /// The path to the file. The path must be relative/virtual. + /// The Content-Type header of the response. + public VirtualFileResult(string fileName, string contentType) + : this(fileName, MediaTypeHeaderValue.Parse(contentType)) + { + } + + /// + /// Creates a new instance with + /// the provided and the + /// provided . + /// + /// The path to the file. The path must be relative/virtual. + /// The Content-Type header of the response. + public VirtualFileResult(string fileName, MediaTypeHeaderValue contentType) + : base(contentType.ToString()) + { + FileName = fileName ?? throw new ArgumentNullException(nameof(fileName)); + } + + /// + /// Gets or sets the path to the file that will be sent back as the response. + /// + public string FileName + { + get => _fileName; + [MemberNotNull(nameof(_fileName))] + set => _fileName = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + public Task ExecuteAsync(HttpContext httpContext) + { + var hostingEnvironment = httpContext.RequestServices.GetRequiredService(); + var logger = httpContext.RequestServices.GetRequiredService>(); + + var fileInfo = GetFileInformation(hostingEnvironment.WebRootFileProvider); + if (!fileInfo.Exists) + { + throw new FileNotFoundException($"Could not find file: {FileName}.", FileName); + } + + Log.ExecutingFileResult(logger, this); + + var lastModified = LastModified ?? fileInfo.LastModified; + var fileResultInfo = new FileResultInfo + { + ContentType = ContentType, + FileDownloadName = FileDownloadName, + EnableRangeProcessing = EnableRangeProcessing, + EntityTag = EntityTag, + LastModified = lastModified, + }; + + var (range, rangeLength, serveBody) = FileResultHelper.SetHeadersAndLog( + httpContext, + fileResultInfo, + fileInfo.Length, + EnableRangeProcessing, + lastModified, + EntityTag, + logger); + + if (!serveBody) + { + return Task.CompletedTask; + } + + if (range != null) + { + FileResultHelper.Log.WritingRangeToBody(logger); + } + + var response = httpContext.Response; + var offset = 0L; + var count = (long?)null; + if (range != null) + { + offset = range.From ?? 0L; + count = rangeLength; + } + + return response.SendFileAsync( + fileInfo, + offset, + count); + } + + internal IFileInfo GetFileInformation(IFileProvider fileProvider) + { + var normalizedPath = FileName; + if (normalizedPath.StartsWith("~", StringComparison.Ordinal)) + { + normalizedPath = normalizedPath.Substring(1); + } + + var fileInfo = fileProvider.GetFileInfo(normalizedPath); + return fileInfo; + } + } +} diff --git a/src/Http/Http.Results/test/AcceptedAtRouteResultTests.cs b/src/Http/Http.Results/test/AcceptedAtRouteResultTests.cs new file mode 100644 index 000000000000..cca95a941621 --- /dev/null +++ b/src/Http/Http.Results/test/AcceptedAtRouteResultTests.cs @@ -0,0 +1,120 @@ +// Copyright (c) .NET Foundation. 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.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class AcceptedAtRouteResultTests + { + [Fact] + public async Task ExecuteResultAsync_FormatsData() + { + // Arrange + var url = "testAction"; + var linkGenerator = new TestLinkGenerator { Url = url }; + var httpContext = GetHttpContext(linkGenerator); + var stream = new MemoryStream(); + httpContext.Response.Body = stream; + + var routeValues = new RouteValueDictionary(new Dictionary() + { + { "test", "case" }, + { "sample", "route" } + }); + + // Act + var result = new AcceptedAtRouteResult( + routeName: "sample", + routeValues: routeValues, + value: "Hello world"); + await result.ExecuteAsync(httpContext); + + // Assert + var response = Encoding.UTF8.GetString(stream.ToArray()); + Assert.Equal("\"Hello world\"", response); + } + + public static TheoryData AcceptedAtRouteData + { + get + { + return new TheoryData + { + null, + new Dictionary() + { + { "hello", "world" } + }, + new RouteValueDictionary( + new Dictionary() + { + { "test", "case" }, + { "sample", "route" } + }), + }; + } + } + + [Theory] + [MemberData(nameof(AcceptedAtRouteData))] + public async Task ExecuteResultAsync_SetsStatusCodeAndLocationHeader(object values) + { + // Arrange + var expectedUrl = "testAction"; + var linkGenerator = new TestLinkGenerator { Url = expectedUrl }; + var httpContext = GetHttpContext(linkGenerator); + + // Act + var result = new AcceptedAtRouteResult(routeValues: values, value: null); + await result.ExecuteAsync(httpContext); + + // Assert + Assert.Equal(StatusCodes.Status202Accepted, httpContext.Response.StatusCode); + Assert.Equal(expectedUrl, httpContext.Response.Headers["Location"]); + } + + [Fact] + public async Task ExecuteResultAsync_ThrowsIfRouteUrlIsNull() + { + // Arrange + var linkGenerator = new TestLinkGenerator(); + var httpContext = GetHttpContext(linkGenerator); + + // Act + var result = new AcceptedAtRouteResult( + routeName: null, + routeValues: new Dictionary(), + value: null); + + // Assert + await ExceptionAssert.ThrowsAsync(() => + result.ExecuteAsync(httpContext), + "No route matches the supplied values."); + } + + private static HttpContext GetHttpContext(LinkGenerator linkGenerator) + { + var httpContext = new DefaultHttpContext(); + httpContext.RequestServices = CreateServices(linkGenerator); + return httpContext; + } + + private static IServiceProvider CreateServices(LinkGenerator linkGenerator) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(linkGenerator); + return services.BuildServiceProvider(); + } + } +} diff --git a/src/Http/Http.Results/test/AcceptedResultTests.cs b/src/Http/Http.Results/test/AcceptedResultTests.cs new file mode 100644 index 000000000000..7ba7d9c8bc0c --- /dev/null +++ b/src/Http/Http.Results/test/AcceptedResultTests.cs @@ -0,0 +1,61 @@ +// Copyright (c) .NET Foundation. 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 System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class AcceptedResultTests + { + [Fact] + public async Task ExecuteResultAsync_FormatsData() + { + // Arrange + var httpContext = GetHttpContext(); + var stream = new MemoryStream(); + httpContext.Response.Body = stream; + // Act + var result = new AcceptedResult("my-location", value: "Hello world"); + await result.ExecuteAsync(httpContext); + + // Assert + var response = Encoding.UTF8.GetString(stream.ToArray()); + Assert.Equal("\"Hello world\"", response); + } + + [Fact] + public async Task ExecuteResultAsync_SetsStatusCodeAndLocationHeader() + { + // Arrange + var expectedUrl = "testAction"; + var httpContext = GetHttpContext(); + + // Act + var result = new AcceptedResult(expectedUrl, value: "some-value"); + await result.ExecuteAsync(httpContext); + + // Assert + Assert.Equal(StatusCodes.Status202Accepted, httpContext.Response.StatusCode); + Assert.Equal(expectedUrl, httpContext.Response.Headers["Location"]); + } + + private static HttpContext GetHttpContext() + { + var httpContext = new DefaultHttpContext(); + httpContext.RequestServices = CreateServices(); + return httpContext; + } + + private static IServiceProvider CreateServices() + { + var services = new ServiceCollection(); + services.AddLogging(); + return services.BuildServiceProvider(); + } + } +} diff --git a/src/Http/Http.Results/test/BadRequestObjectResultTests.cs b/src/Http/Http.Results/test/BadRequestObjectResultTests.cs new file mode 100644 index 000000000000..6fb262fa1847 --- /dev/null +++ b/src/Http/Http.Results/test/BadRequestObjectResultTests.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class BadRequestObjectResultTests + { + [Fact] + public void BadRequestObjectResult_SetsStatusCodeAndValue() + { + // Arrange & Act + var obj = new object(); + var badRequestObjectResult = new BadRequestObjectResult(obj); + + // Assert + Assert.Equal(StatusCodes.Status400BadRequest, badRequestObjectResult.StatusCode); + Assert.Equal(obj, badRequestObjectResult.Value); + } + } +} diff --git a/src/Http/Http.Results/test/BadRequestResultTests.cs b/src/Http/Http.Results/test/BadRequestResultTests.cs new file mode 100644 index 000000000000..9e5dd8f6aead --- /dev/null +++ b/src/Http/Http.Results/test/BadRequestResultTests.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class BadRequestResultTests + { + [Fact] + public void BadRequestResult_InitializesStatusCode() + { + // Arrange & act + var badRequest = new BadRequestResult(); + + // Assert + Assert.Equal(StatusCodes.Status400BadRequest, badRequest.StatusCode); + } + } +} diff --git a/src/Http/Http.Results/test/ChallengeResultTest.cs b/src/Http/Http.Results/test/ChallengeResultTest.cs new file mode 100644 index 000000000000..caecfc3d548c --- /dev/null +++ b/src/Http/Http.Results/test/ChallengeResultTest.cs @@ -0,0 +1,62 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class ChallengeResultTest + { + [Fact] + public async Task ChallengeResult_ExecuteAsync() + { + // Arrange + var result = new ChallengeResult("", null); + var auth = new Mock(); + var httpContext = GetHttpContext(auth); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + auth.Verify(c => c.ChallengeAsync(httpContext, "", null), Times.Exactly(1)); + } + + [Fact] + public async Task ChallengeResult_ExecuteAsync_NoSchemes() + { + // Arrange + var result = new ChallengeResult(new string[] { }, null); + var auth = new Mock(); + var httpContext = GetHttpContext(auth); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + auth.Verify(c => c.ChallengeAsync(httpContext, null, null), Times.Exactly(1)); + } + + private static DefaultHttpContext GetHttpContext(Mock auth) + { + var httpContext = new DefaultHttpContext(); + httpContext.RequestServices = CreateServices() + .AddSingleton(auth.Object) + .BuildServiceProvider(); + return httpContext; + } + + private static IServiceCollection CreateServices() + { + var services = new ServiceCollection(); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + return services; + } + } +} diff --git a/src/Http/Http.Results/test/ConflictObjectResultTest.cs b/src/Http/Http.Results/test/ConflictObjectResultTest.cs new file mode 100644 index 000000000000..9be97df49de4 --- /dev/null +++ b/src/Http/Http.Results/test/ConflictObjectResultTest.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class ConflictObjectResultTest + { + [Fact] + public void ConflictObjectResult_SetsStatusCodeAndValue() + { + // Arrange & Act + var obj = new object(); + var conflictObjectResult = new ConflictObjectResult(obj); + + // Assert + Assert.Equal(StatusCodes.Status409Conflict, conflictObjectResult.StatusCode); + Assert.Equal(obj, conflictObjectResult.Value); + } + } +} diff --git a/src/Http/Http.Results/test/ConflictResultTest.cs b/src/Http/Http.Results/test/ConflictResultTest.cs new file mode 100644 index 000000000000..d21eb6fc2dc8 --- /dev/null +++ b/src/Http/Http.Results/test/ConflictResultTest.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class ConflictResultTest + { + [Fact] + public void ConflictResult_InitializesStatusCode() + { + // Arrange & act + var conflictResult = new ConflictResult(); + + // Assert + Assert.Equal(StatusCodes.Status409Conflict, conflictResult.StatusCode); + } + } +} diff --git a/src/Http/Http.Results/test/ContentResultTest.cs b/src/Http/Http.Results/test/ContentResultTest.cs new file mode 100644 index 000000000000..d67688276342 --- /dev/null +++ b/src/Http/Http.Results/test/ContentResultTest.cs @@ -0,0 +1,152 @@ +// Copyright (c) .NET Foundation. 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 System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class ContentResultTest + { + [Fact] + public async Task ContentResult_ExecuteAsync_Response_NullContent_SetsContentTypeAndEncoding() + { + // Arrange + var contentResult = new ContentResult + { + Content = null, + ContentType = new MediaTypeHeaderValue("text/plain") + { + Encoding = Encoding.Unicode + }.ToString() + }; + var httpContext = GetHttpContext(); + + // Act + await contentResult.ExecuteAsync(httpContext); + + // Assert + Assert.Equal("text/plain; charset=utf-16", httpContext.Response.ContentType); + } + + public static TheoryData ContentResultContentTypeData + { + get + { + // contentType, content, responseContentType, expectedContentType, expectedData + return new TheoryData + { + { + null, + "κόσμε", + null, + "text/plain; charset=utf-8", + new byte[] { 206, 186, 225, 189, 185, 207, 131, 206, 188, 206, 181 } //utf-8 without BOM + }, + { + new MediaTypeHeaderValue("text/foo"), + "κόσμε", + null, + "text/foo", + new byte[] { 206, 186, 225, 189, 185, 207, 131, 206, 188, 206, 181 } //utf-8 without BOM + }, + { + MediaTypeHeaderValue.Parse("text/foo;p1=p1-value"), + "κόσμε", + null, + "text/foo; p1=p1-value", + new byte[] { 206, 186, 225, 189, 185, 207, 131, 206, 188, 206, 181 } //utf-8 without BOM + }, + { + new MediaTypeHeaderValue("text/foo") { Encoding = Encoding.ASCII }, + "abcd", + null, + "text/foo; charset=us-ascii", + new byte[] { 97, 98, 99, 100 } + }, + { + null, + "abcd", + "text/bar", + "text/bar", + new byte[] { 97, 98, 99, 100 } + }, + { + null, + "abcd", + "application/xml; charset=us-ascii", + "application/xml; charset=us-ascii", + new byte[] { 97, 98, 99, 100 } + }, + { + null, + "abcd", + "Invalid content type", + "Invalid content type", + new byte[] { 97, 98, 99, 100 } + }, + { + new MediaTypeHeaderValue("text/foo") { Charset = "us-ascii" }, + "abcd", + "text/bar", + "text/foo; charset=us-ascii", + new byte[] { 97, 98, 99, 100 } + }, + }; + } + } + + [Theory] + [MemberData(nameof(ContentResultContentTypeData))] + public async Task ContentResult_ExecuteAsync_SetContentTypeAndEncoding_OnResponse( + MediaTypeHeaderValue contentType, + string content, + string responseContentType, + string expectedContentType, + byte[] expectedContentData) + { + // Arrange + var contentResult = new ContentResult + { + Content = content, + ContentType = contentType?.ToString() + }; + var httpContext = GetHttpContext(); + var memoryStream = new MemoryStream(); + httpContext.Response.Body = memoryStream; + httpContext.Response.ContentType = responseContentType; + + // Act + await contentResult.ExecuteAsync(httpContext); + + // Assert + var finalResponseContentType = httpContext.Response.ContentType; + Assert.Equal(expectedContentType, finalResponseContentType); + Assert.Equal(expectedContentData, memoryStream.ToArray()); + Assert.Equal(expectedContentData.Length, httpContext.Response.ContentLength); + } + + private static IServiceCollection CreateServices() + { + var services = new ServiceCollection(); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + return services; + } + + private static HttpContext GetHttpContext() + { + var services = CreateServices(); + + var httpContext = new DefaultHttpContext(); + httpContext.RequestServices = services.BuildServiceProvider(); + + return httpContext; + } + } +} diff --git a/src/Http/Http.Results/test/CreatedAtRouteResultTests.cs b/src/Http/Http.Results/test/CreatedAtRouteResultTests.cs new file mode 100644 index 000000000000..ad6d0e7430c4 --- /dev/null +++ b/src/Http/Http.Results/test/CreatedAtRouteResultTests.cs @@ -0,0 +1,93 @@ +// Copyright (c) .NET Foundation. 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.AspNetCore.Routing; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public partial class CreatedAtRouteResultTests + { + public static IEnumerable CreatedAtRouteData + { + get + { + yield return new object[] { null }; + yield return + new object[] { + new Dictionary() { { "hello", "world" } } + }; + yield return + new object[] { + new RouteValueDictionary(new Dictionary() { + { "test", "case" }, + { "sample", "route" } + }) + }; + } + } + + [Theory] + [MemberData(nameof(CreatedAtRouteData))] + public async Task CreatedAtRouteResult_ReturnsStatusCode_SetsLocationHeader(object values) + { + // Arrange + var expectedUrl = "testAction"; + var httpContext = GetHttpContext(expectedUrl); + + // Act + var result = new CreatedAtRouteResult(routeName: null, routeValues: values, value: null); + await result.ExecuteAsync(httpContext); + + // Assert + Assert.Equal(StatusCodes.Status201Created, httpContext.Response.StatusCode); + Assert.Equal(expectedUrl, httpContext.Response.Headers["Location"]); + } + + [Fact] + public async Task CreatedAtRouteResult_ThrowsOnNullUrl() + { + // Arrange + var httpContext = GetHttpContext(expectedUrl: null); + + var result = new CreatedAtRouteResult( + routeName: null, + routeValues: new Dictionary(), + value: null); + + // Act & Assert + await ExceptionAssert.ThrowsAsync( + async () => await result.ExecuteAsync(httpContext), + "No route matches the supplied values."); + } + + private static HttpContext GetHttpContext(string expectedUrl) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.PathBase = new PathString(""); + httpContext.Response.Body = new MemoryStream(); + httpContext.RequestServices = CreateServices(expectedUrl); + return httpContext; + } + + private static IServiceProvider CreateServices(string expectedUrl) + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(new TestLinkGenerator + { + Url = expectedUrl + }); + + return services.BuildServiceProvider(); + } + } +} diff --git a/src/Http/Http.Results/test/CreatedResultTest.cs b/src/Http/Http.Results/test/CreatedResultTest.cs new file mode 100644 index 000000000000..efe27830b3a4 --- /dev/null +++ b/src/Http/Http.Results/test/CreatedResultTest.cs @@ -0,0 +1,79 @@ +// Copyright (c) .NET Foundation. 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 System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class CreatedResultTests + { + [Fact] + public void CreatedResult_SetsLocation() + { + // Arrange + var location = "http://test/location"; + + // Act + var result = new CreatedResult(location, "testInput"); + + // Assert + Assert.Same(location, result.Location); + } + + [Fact] + public async Task CreatedResult_ReturnsStatusCode_SetsLocationHeader() + { + // Arrange + var location = "/test/"; + var httpContext = GetHttpContext(); + var result = new CreatedResult(location, "testInput"); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + Assert.Equal(StatusCodes.Status201Created, httpContext.Response.StatusCode); + Assert.Equal(location, httpContext.Response.Headers["Location"]); + } + + [Fact] + public async Task CreatedResult_OverwritesLocationHeader() + { + // Arrange + var location = "/test/"; + var httpContext = GetHttpContext(); + httpContext.Response.Headers["Location"] = "/different/location/"; + var result = new CreatedResult(location, "testInput"); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + Assert.Equal(StatusCodes.Status201Created, httpContext.Response.StatusCode); + Assert.Equal(location, httpContext.Response.Headers["Location"]); + } + + private static HttpContext GetHttpContext() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.PathBase = new PathString(""); + httpContext.Response.Body = new MemoryStream(); + httpContext.RequestServices = CreateServices(); + return httpContext; + } + + private static IServiceProvider CreateServices() + { + var services = new ServiceCollection(); + services.AddSingleton(); + + return services.BuildServiceProvider(); + } + } +} diff --git a/src/Http/Http.Results/test/FileContentResultTest.cs b/src/Http/Http.Results/test/FileContentResultTest.cs new file mode 100644 index 000000000000..1822cb08bc29 --- /dev/null +++ b/src/Http/Http.Results/test/FileContentResultTest.cs @@ -0,0 +1,38 @@ +// Copyright (c) .NET Foundation. 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.Threading.Tasks; +using Microsoft.AspNetCore.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class FileContentResultTest : FileContentResultTestBase + { + protected override Task ExecuteAsync( + HttpContext httpContext, + byte[] buffer, + string contentType, + DateTimeOffset? lastModified = null, + EntityTagHeaderValue entityTag = null, + bool enableRangeProcessing = false) + { + var result = new FileContentResult(buffer, contentType) + { + EntityTag = entityTag, + LastModified = lastModified, + EnableRangeProcessing = enableRangeProcessing, + }; + + httpContext.RequestServices = new ServiceCollection() + .AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)) + .BuildServiceProvider(); + + return result.ExecuteAsync(httpContext); + } + } +} diff --git a/src/Http/Http.Results/test/FileStreamResultTest.cs b/src/Http/Http.Results/test/FileStreamResultTest.cs new file mode 100644 index 000000000000..d7e712d7a53e --- /dev/null +++ b/src/Http/Http.Results/test/FileStreamResultTest.cs @@ -0,0 +1,86 @@ +// Copyright (c) .NET Foundation. 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 System.Threading.Tasks; +using Microsoft.AspNetCore.Internal; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class FileStreamResultTest : FileStreamResultTestBase + { + protected override Task ExecuteAsync( + HttpContext httpContext, + Stream stream, + string contentType, + DateTimeOffset? lastModified = null, + EntityTagHeaderValue entityTag = null, + bool enableRangeProcessing = false) + { + var fileStreamResult = new FileStreamResult(stream, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + EnableRangeProcessing = enableRangeProcessing + }; + + return fileStreamResult.ExecuteAsync(httpContext); + } + + [Fact] + public void Constructor_SetsFileName() + { + // Arrange + var stream = Stream.Null; + + // Act + var result = new FileStreamResult(stream, "text/plain"); + + // Assert + Assert.Equal(stream, result.FileStream); + } + + [Fact] + public void Constructor_SetsContentTypeAndParameters() + { + // Arrange + var stream = Stream.Null; + var contentType = "text/plain; charset=us-ascii; p1=p1-value"; + var expectedMediaType = contentType; + + // Act + var result = new FileStreamResult(stream, contentType); + + // Assert + Assert.Equal(stream, result.FileStream); + Assert.Equal(expectedMediaType, result.ContentType); + } + + [Fact] + public void Constructor_SetsLastModifiedAndEtag() + { + // Arrange + var stream = Stream.Null; + var contentType = "text/plain"; + var expectedMediaType = contentType; + var lastModified = new DateTimeOffset(); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + + // Act + var result = new FileStreamResult(stream, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + }; + + // Assert + Assert.Equal(lastModified, result.LastModified); + Assert.Equal(entityTag, result.EntityTag); + Assert.Equal(expectedMediaType, result.ContentType); + } + + } +} diff --git a/src/Http/Http.Results/test/ForbidResultTest.cs b/src/Http/Http.Results/test/ForbidResultTest.cs new file mode 100644 index 000000000000..4ce720564892 --- /dev/null +++ b/src/Http/Http.Results/test/ForbidResultTest.cs @@ -0,0 +1,129 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class ForbidResultTest + { + [Fact] + public async Task ExecuteResultAsync_InvokesForbidAsyncOnAuthenticationService() + { + // Arrange + var auth = new Mock(); + auth + .Setup(c => c.ForbidAsync(It.IsAny(), "", null)) + .Returns(Task.CompletedTask) + .Verifiable(); + var httpContext = GetHttpContext(auth.Object); + var result = new ForbidResult("", null); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + auth.Verify(); + } + + [Fact] + public async Task ExecuteResultAsync_InvokesForbidAsyncOnAllConfiguredSchemes() + { + // Arrange + var authProperties = new AuthenticationProperties(); + var auth = new Mock(); + auth + .Setup(c => c.ForbidAsync(It.IsAny(), "Scheme1", authProperties)) + .Returns(Task.CompletedTask) + .Verifiable(); + auth + .Setup(c => c.ForbidAsync(It.IsAny(), "Scheme2", authProperties)) + .Returns(Task.CompletedTask) + .Verifiable(); + var httpContext = GetHttpContext(auth.Object); + var result = new ForbidResult(new[] { "Scheme1", "Scheme2" }, authProperties); + var routeData = new RouteData(); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + auth.Verify(); + } + + public static TheoryData ExecuteResultAsync_InvokesForbidAsyncWithAuthPropertiesData => + new TheoryData + { + null, + new AuthenticationProperties() + }; + + [Theory] + [MemberData(nameof(ExecuteResultAsync_InvokesForbidAsyncWithAuthPropertiesData))] + public async Task ExecuteResultAsync_InvokesForbidAsyncWithAuthProperties(AuthenticationProperties expected) + { + // Arrange + var auth = new Mock(); + auth + .Setup(c => c.ForbidAsync(It.IsAny(), null, expected)) + .Returns(Task.CompletedTask) + .Verifiable(); + var result = new ForbidResult(expected); + var httpContext = GetHttpContext(auth.Object); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + auth.Verify(); + } + + [Theory] + [MemberData(nameof(ExecuteResultAsync_InvokesForbidAsyncWithAuthPropertiesData))] + public async Task ExecuteResultAsync_InvokesForbidAsyncWithAuthProperties_WhenAuthenticationSchemesIsEmpty( + AuthenticationProperties expected) + { + // Arrange + var auth = new Mock(); + auth + .Setup(c => c.ForbidAsync(It.IsAny(), null, expected)) + .Returns(Task.CompletedTask) + .Verifiable(); + var httpContext = GetHttpContext(auth.Object); + var result = new ForbidResult(expected) + { + AuthenticationSchemes = new string[0] + }; + var routeData = new RouteData(); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + auth.Verify(); + } + + private static DefaultHttpContext GetHttpContext(IAuthenticationService auth) + { + var httpContext = new DefaultHttpContext(); + httpContext.RequestServices = CreateServices() + .AddSingleton(auth) + .BuildServiceProvider(); + return httpContext; + } + + private static IServiceCollection CreateServices() + { + var services = new ServiceCollection(); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + return services; + } + } +} diff --git a/src/Http/Http.Results/test/LocalRedirectResultTest.cs b/src/Http/Http.Results/test/LocalRedirectResultTest.cs new file mode 100644 index 000000000000..283148da985c --- /dev/null +++ b/src/Http/Http.Results/test/LocalRedirectResultTest.cs @@ -0,0 +1,138 @@ +// Copyright (c) .NET Foundation. 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.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class LocalRedirectResultTest + { + [Fact] + public void Constructor_WithParameterUrl_SetsResultUrlAndNotPermanentOrPreserveMethod() + { + // Arrange + var url = "/test/url"; + + // Act + var result = new LocalRedirectResult(url); + + // Assert + Assert.False(result.PreserveMethod); + Assert.False(result.Permanent); + Assert.Same(url, result.Url); + } + + [Fact] + public void Constructor_WithParameterUrlAndPermanent_SetsResultUrlAndPermanentNotPreserveMethod() + { + // Arrange + var url = "/test/url"; + + // Act + var result = new LocalRedirectResult(url, permanent: true); + + // Assert + Assert.False(result.PreserveMethod); + Assert.True(result.Permanent); + Assert.Same(url, result.Url); + } + + [Fact] + public void Constructor_WithParameterUrlAndPermanent_SetsResultUrlPermanentAndPreserveMethod() + { + // Arrange + var url = "/test/url"; + + // Act + var result = new LocalRedirectResult(url, permanent: true, preserveMethod: true); + + // Assert + Assert.True(result.PreserveMethod); + Assert.True(result.Permanent); + Assert.Same(url, result.Url); + } + + [Fact] + public async Task Execute_ReturnsExpectedValues() + { + // Arrange + var appRoot = "/"; + var contentPath = "~/Home/About"; + var expectedPath = "/Home/About"; + + var httpContext = GetHttpContext(appRoot); + var result = new LocalRedirectResult(contentPath); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + Assert.Equal(expectedPath, httpContext.Response.Headers.Location.ToString()); + Assert.Equal(StatusCodes.Status302Found, httpContext.Response.StatusCode); + } + + [Theory] + [InlineData("", "//")] + [InlineData("", "/\\")] + [InlineData("", "//foo")] + [InlineData("", "/\\foo")] + [InlineData("", "Home/About")] + [InlineData("/myapproot", "http://www.example.com")] + public async Task Execute_Throws_ForNonLocalUrl( + string appRoot, + string contentPath) + { + // Arrange + var httpContext = GetHttpContext(appRoot); + var result = new LocalRedirectResult(contentPath); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => result.ExecuteAsync(httpContext)); + Assert.Equal( + "The supplied URL is not local. A URL with an absolute path is considered local if it does not " + + "have a host/authority part. URLs using virtual paths ('~/') are also local.", + exception.Message); + } + + [Theory] + [InlineData("", "~//")] + [InlineData("", "~/\\")] + [InlineData("", "~//foo")] + [InlineData("", "~/\\foo")] + public async Task Execute_Throws_ForNonLocalUrlTilde( + string appRoot, + string contentPath) + { + // Arrange + var httpContext = GetHttpContext(appRoot); + var result = new LocalRedirectResult(contentPath); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => result.ExecuteAsync(httpContext)); + Assert.Equal( + "The supplied URL is not local. A URL with an absolute path is considered local if it does not " + + "have a host/authority part. URLs using virtual paths ('~/') are also local.", + exception.Message); + } + + private static IServiceProvider GetServiceProvider() + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddTransient(typeof(ILogger<>), typeof(NullLogger<>)); + return serviceCollection.BuildServiceProvider(); + } + + private static HttpContext GetHttpContext(string appRoot) + { + var httpContext = new DefaultHttpContext(); + httpContext.RequestServices = GetServiceProvider(); + httpContext.Request.PathBase = new PathString(appRoot); + return httpContext; + } + } +} diff --git a/src/Http/Http.Results/test/Microsoft.AspNetCore.Http.Results.Tests.csproj b/src/Http/Http.Results/test/Microsoft.AspNetCore.Http.Results.Tests.csproj new file mode 100644 index 000000000000..270acfe04ebb --- /dev/null +++ b/src/Http/Http.Results/test/Microsoft.AspNetCore.Http.Results.Tests.csproj @@ -0,0 +1,15 @@ + + + + $(DefaultNetCoreTargetFramework) + + + + + + + + + + + diff --git a/src/Http/Http.Results/test/NotFoundObjectResultTest.cs b/src/Http/Http.Results/test/NotFoundObjectResultTest.cs new file mode 100644 index 000000000000..be09b3660270 --- /dev/null +++ b/src/Http/Http.Results/test/NotFoundObjectResultTest.cs @@ -0,0 +1,67 @@ +// Copyright (c) .NET Foundation. 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 System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class NotFoundObjectResultTest + { + [Fact] + public void NotFoundObjectResult_InitializesStatusCode() + { + // Arrange & act + var notFound = new NotFoundObjectResult(null); + + // Assert + Assert.Equal(StatusCodes.Status404NotFound, notFound.StatusCode); + } + + [Fact] + public void NotFoundObjectResult_InitializesStatusCodeAndResponseContent() + { + // Arrange & act + var notFound = new NotFoundObjectResult("Test Content"); + + // Assert + Assert.Equal(StatusCodes.Status404NotFound, notFound.StatusCode); + Assert.Equal("Test Content", notFound.Value); + } + + [Fact] + public async Task NotFoundObjectResult_ExecuteSuccessful() + { + // Arrange + var httpContext = GetHttpContext(); + var result = new NotFoundObjectResult("Test Content"); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + Assert.Equal(StatusCodes.Status404NotFound, httpContext.Response.StatusCode); + } + + private static HttpContext GetHttpContext() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.PathBase = new PathString(""); + httpContext.Response.Body = new MemoryStream(); + httpContext.RequestServices = CreateServices(); + return httpContext; + } + + private static IServiceProvider CreateServices() + { + var services = new ServiceCollection(); + services.AddSingleton(); + return services.BuildServiceProvider(); + } + } +} diff --git a/src/Http/Http.Results/test/NotFoundResultTests.cs b/src/Http/Http.Results/test/NotFoundResultTests.cs new file mode 100644 index 000000000000..ffa21c67a33b --- /dev/null +++ b/src/Http/Http.Results/test/NotFoundResultTests.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class NotFoundResultTests + { + [Fact] + public void NotFoundResult_InitializesStatusCode() + { + // Arrange & act + var notFound = new NotFoundResult(); + + // Assert + Assert.Equal(StatusCodes.Status404NotFound, notFound.StatusCode); + } + } +} diff --git a/src/Http/Http.Results/test/ObjectResultTests.cs b/src/Http/Http.Results/test/ObjectResultTests.cs new file mode 100644 index 000000000000..3588eb28aeda --- /dev/null +++ b/src/Http/Http.Results/test/ObjectResultTests.cs @@ -0,0 +1,65 @@ +// Copyright (c) .NET Foundation. 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 System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class ObjectResultTests + { + [Fact] + public async Task ObjectResult_ExecuteAsync_SetsStatusCode() + { + // Arrange + var result = new ObjectResult("Hello", 407); + + var httpContext = new DefaultHttpContext() + { + RequestServices = CreateServices(), + }; + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + Assert.Equal(407, httpContext.Response.StatusCode); + } + + [Fact] + public async Task ObjectResult_ExecuteAsync_JsonSerializesBody() + { + // Arrange + var result = new ObjectResult("Hello", 407); + var stream = new MemoryStream(); + var httpContext = new DefaultHttpContext() + { + RequestServices = CreateServices(), + Response = + { + Body = stream, + }, + }; + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + Assert.Equal("\"Hello\"", Encoding.UTF8.GetString(stream.ToArray())); + } + + private static IServiceProvider CreateServices() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + + return services.BuildServiceProvider(); + } + } +} diff --git a/src/Http/Http.Results/test/OkObjectResultTest.cs b/src/Http/Http.Results/test/OkObjectResultTest.cs new file mode 100644 index 000000000000..5ba062ed8dd8 --- /dev/null +++ b/src/Http/Http.Results/test/OkObjectResultTest.cs @@ -0,0 +1,46 @@ +// Copyright (c) .NET Foundation. 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 System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class OkObjectResultTest + { + [Fact] + public async Task OkObjectResult_SetsStatusCodeAndValue() + { + // Arrange + var result = new OkObjectResult("Hello world"); + var httpContext = GetHttpContext(); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode); + } + + private static HttpContext GetHttpContext() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.PathBase = new PathString(""); + httpContext.Response.Body = new MemoryStream(); + httpContext.RequestServices = CreateServices(); + return httpContext; + } + + private static IServiceProvider CreateServices() + { + var services = new ServiceCollection(); + services.AddSingleton(); + return services.BuildServiceProvider(); + } + } +} diff --git a/src/Http/Http.Results/test/OkResultTest.cs b/src/Http/Http.Results/test/OkResultTest.cs new file mode 100644 index 000000000000..a32ca95201e0 --- /dev/null +++ b/src/Http/Http.Results/test/OkResultTest.cs @@ -0,0 +1,37 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class OkResultTest + { + [Fact] + public async Task HttpOkResult_SetsStatusCode() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.RequestServices = CreateServices().BuildServiceProvider(); + + var result = new OkResult(); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode); + } + + private static IServiceCollection CreateServices() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + return services; + } + } +} diff --git a/src/Http/Http.Results/test/PhysicalFileResultTest.cs b/src/Http/Http.Results/test/PhysicalFileResultTest.cs new file mode 100644 index 000000000000..bf1fdd1b4654 --- /dev/null +++ b/src/Http/Http.Results/test/PhysicalFileResultTest.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation. 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.Threading.Tasks; +using Microsoft.AspNetCore.Internal; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class PhysicalFileResultTest : PhysicalFileResultTestBase + { + protected override Task ExecuteAsync( + HttpContext httpContext, + string path, + string contentType, + DateTimeOffset? lastModified = null, + EntityTagHeaderValue entityTag = null, + bool enableRangeProcessing = false) + { + var fileResult = new PhysicalFileResult(path, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + EnableRangeProcessing = enableRangeProcessing, + GetFileInfoWrapper = (path) => + { + var lastModified = DateTimeOffset.MinValue.AddDays(1); + return new() + { + Exists = true, + Length = 34, + LastWriteTimeUtc = new DateTimeOffset(lastModified.Year, lastModified.Month, lastModified.Day, lastModified.Hour, lastModified.Minute, lastModified.Second, TimeSpan.FromSeconds(0)) + }; + } + }; + + return fileResult.ExecuteAsync(httpContext); + } + } +} diff --git a/src/Mvc/Mvc.Core/test/RedirectActionResultTest.cs b/src/Http/Http.Results/test/RedirectResultTest.cs similarity index 50% rename from src/Mvc/Mvc.Core/test/RedirectActionResultTest.cs rename to src/Http/Http.Results/test/RedirectResultTest.cs index 9b139431fd88..005407107c5c 100644 --- a/src/Mvc/Mvc.Core/test/RedirectActionResultTest.cs +++ b/src/Http/Http.Results/test/RedirectResultTest.cs @@ -1,13 +1,13 @@ // Copyright (c) .NET Foundation. 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.Threading.Tasks; +using Microsoft.AspNetCore.Internal; using Xunit; -namespace Microsoft.AspNetCore.Mvc +namespace Microsoft.AspNetCore.Http.Result { - public class RedirectActionResultTest + public class RedirectResultTest : RedirectResultTestBase { [Fact] public void RedirectResult_Constructor_WithParameterUrl_SetsResultUrlAndNotPermanentOrPreserveMethod() @@ -54,43 +54,10 @@ public void RedirectResult_Constructor_WithParameterUrlPermanentAndPreservesMeth Assert.Same(url, result.Url); } - [Theory] - [InlineData("", "/Home/About", "/Home/About")] - [InlineData("/myapproot", "/test", "/test")] - public async Task Execute_ReturnsContentPath_WhenItDoesNotStartWithTilde( - string appRoot, - string contentPath, - string expectedPath) + protected override Task ExecuteAsync(HttpContext httpContext, string contentPath) { - var action - = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseRedirectResultTest.Execute_ReturnsContentPath_WhenItDoesNotStartWithTilde( - appRoot, - contentPath, - expectedPath, - action); - } - - [Theory] - [InlineData(null, "~/Home/About", "/Home/About")] - [InlineData("/", "~/Home/About", "/Home/About")] - [InlineData("/", "~/", "/")] - [InlineData("", "~/Home/About", "/Home/About")] - [InlineData("/myapproot", "~/", "/myapproot/")] - public async Task Execute_ReturnsAppRelativePath_WhenItStartsWithTilde( - string appRoot, - string contentPath, - string expectedPath) - { - var action = - new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseRedirectResultTest.Execute_ReturnsAppRelativePath_WhenItStartsWithTilde( - appRoot, - contentPath, - expectedPath, - action); + var redirectResult = new RedirectResult(contentPath); + return redirectResult.ExecuteAsync(httpContext); } } } diff --git a/src/Http/Http.Results/test/RedirectToRouteResultTest.cs b/src/Http/Http.Results/test/RedirectToRouteResultTest.cs new file mode 100644 index 000000000000..3b43e65f301d --- /dev/null +++ b/src/Http/Http.Results/test/RedirectToRouteResultTest.cs @@ -0,0 +1,111 @@ +// Copyright (c) .NET Foundation. 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.Threading.Tasks; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class RedirectToRouteResultTest + { + [Fact] + public async Task RedirectToRoute_Execute_ThrowsOnNullUrl() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.RequestServices = CreateServices(null).BuildServiceProvider(); + + var result = new RedirectToRouteResult(null, new Dictionary()); + + // Act & Assert + await ExceptionAssert.ThrowsAsync( + async () => + { + await result.ExecuteAsync(httpContext); + }, + "No route matches the supplied values."); + } + + [Fact] + public async Task ExecuteResultAsync_UsesRouteName_ToGenerateLocationHeader() + { + // Arrange + var routeName = "orders_api"; + var locationUrl = "/api/orders/10"; + + var httpContext = GetHttpContext(locationUrl); + + var result = new RedirectToRouteResult(routeName, new { id = 10 }); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + Assert.True(httpContext.Response.Headers.ContainsKey("Location"), "Location header not found"); + Assert.Equal(locationUrl, httpContext.Response.Headers["Location"]); + } + + [Fact] + public async Task ExecuteResultAsync_WithFragment_PassesCorrectValuesToRedirect() + { + // Arrange + var expectedUrl = "/SampleAction#test"; + var expectedStatusCode = StatusCodes.Status301MovedPermanently; + var httpContext = GetHttpContext(expectedUrl); + + var result = new RedirectToRouteResult("Sample", null, true, "test"); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + Assert.Equal(expectedStatusCode, httpContext.Response.StatusCode); + Assert.Equal(expectedUrl, httpContext.Response.Headers["Location"]); + } + + [Fact] + public async Task ExecuteResultAsync_WithFragment_PassesCorrectValuesToRedirect_WithPreserveMethod() + { + // Arrange + var expectedUrl = "/SampleAction#test"; + var expectedStatusCode = StatusCodes.Status308PermanentRedirect; + + var httpContext = GetHttpContext(expectedUrl); + var result = new RedirectToRouteResult("Sample", null, true, true, "test"); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + Assert.Equal(expectedStatusCode, httpContext.Response.StatusCode); + Assert.Equal(expectedUrl, httpContext.Response.Headers["Location"]); + } + + private static HttpContext GetHttpContext(string path) + { + var services = CreateServices(path); + + var httpContext = new DefaultHttpContext(); + httpContext.RequestServices = services.BuildServiceProvider(); + + return httpContext; + } + + private static IServiceCollection CreateServices(string path) + { + var services = new ServiceCollection(); + services.AddSingleton(new TestLinkGenerator { Url = path }); + + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + return services; + } + } +} diff --git a/src/Http/Http.Results/test/SignInResultTest.cs b/src/Http/Http.Results/test/SignInResultTest.cs new file mode 100644 index 000000000000..0930081fc2ba --- /dev/null +++ b/src/Http/Http.Results/test/SignInResultTest.cs @@ -0,0 +1,95 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class SignInResultTest + { + [Fact] + public async Task ExecuteAsync_InvokesSignInAsyncOnAuthenticationManager() + { + // Arrange + var principal = new ClaimsPrincipal(); + var auth = new Mock(); + auth + .Setup(c => c.SignInAsync(It.IsAny(), "", principal, null)) + .Returns(Task.CompletedTask) + .Verifiable(); + + var httpContext = GetHttpContext(auth.Object); + var result = new SignInResult("", principal, null); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + auth.Verify(); + } + + [Fact] + public async Task ExecuteAsync_InvokesSignInAsyncOnAuthenticationManagerWithDefaultScheme() + { + // Arrange + var principal = new ClaimsPrincipal(); + var auth = new Mock(); + auth + .Setup(c => c.SignInAsync(It.IsAny(), null, principal, null)) + .Returns(Task.CompletedTask) + .Verifiable(); + var httpContext = GetHttpContext(auth.Object); + var result = new SignInResult(principal); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + auth.Verify(); + } + + [Fact] + public async Task ExecuteAsync_InvokesSignInAsyncOnConfiguredScheme() + { + // Arrange + var principal = new ClaimsPrincipal(); + var authProperties = new AuthenticationProperties(); + var auth = new Mock(); + auth + .Setup(c => c.SignInAsync(It.IsAny(), "Scheme1", principal, authProperties)) + .Returns(Task.CompletedTask) + .Verifiable(); + var httpContext = GetHttpContext(auth.Object); + var result = new SignInResult("Scheme1", principal, authProperties); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + auth.Verify(); + } + + private static DefaultHttpContext GetHttpContext(IAuthenticationService auth) + { + var httpContext = new DefaultHttpContext(); + httpContext.RequestServices = CreateServices() + .AddSingleton(auth) + .BuildServiceProvider(); + return httpContext; + } + + private static IServiceCollection CreateServices() + { + var services = new ServiceCollection(); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + return services; + } + } +} diff --git a/src/Http/Http.Results/test/SignOutResultTest.cs b/src/Http/Http.Results/test/SignOutResultTest.cs new file mode 100644 index 000000000000..47f4baaca5fc --- /dev/null +++ b/src/Http/Http.Results/test/SignOutResultTest.cs @@ -0,0 +1,95 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class SignOutResultTest + { + [Fact] + public async Task ExecuteAsync_NoArgsInvokesDefaultSignOut() + { + // Arrange + var auth = new Mock(); + auth + .Setup(c => c.SignOutAsync(It.IsAny(), null, null)) + .Returns(Task.CompletedTask) + .Verifiable(); + var httpContext = GetHttpContext(auth.Object); + var result = new SignOutResult(); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + auth.Verify(); + } + + [Fact] + public async Task ExecuteAsync_InvokesSignOutAsyncOnAuthenticationManager() + { + // Arrange + var auth = new Mock(); + auth + .Setup(c => c.SignOutAsync(It.IsAny(), "", null)) + .Returns(Task.CompletedTask) + .Verifiable(); + var httpContext = GetHttpContext(auth.Object); + var result = new SignOutResult("", null); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + auth.Verify(); + } + + [Fact] + public async Task ExecuteAsync_InvokesSignOutAsyncOnAllConfiguredSchemes() + { + // Arrange + var authProperties = new AuthenticationProperties(); + var auth = new Mock(); + auth + .Setup(c => c.SignOutAsync(It.IsAny(), "Scheme1", authProperties)) + .Returns(Task.CompletedTask) + .Verifiable(); + auth + .Setup(c => c.SignOutAsync(It.IsAny(), "Scheme2", authProperties)) + .Returns(Task.CompletedTask) + .Verifiable(); + var httpContext = GetHttpContext(auth.Object); + var result = new SignOutResult(new[] { "Scheme1", "Scheme2" }, authProperties); + + // Act + await result.ExecuteAsync(httpContext); + + // Assert + auth.Verify(); + } + + private static DefaultHttpContext GetHttpContext(IAuthenticationService auth) + { + var httpContext = new DefaultHttpContext(); + httpContext.RequestServices = CreateServices() + .AddSingleton(auth) + .BuildServiceProvider(); + return httpContext; + } + + private static IServiceCollection CreateServices() + { + var services = new ServiceCollection(); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + return services; + } + } +} diff --git a/src/Http/Http.Results/test/StatusCodeResultTests.cs b/src/Http/Http.Results/test/StatusCodeResultTests.cs new file mode 100644 index 000000000000..f7ae16da5e17 --- /dev/null +++ b/src/Http/Http.Results/test/StatusCodeResultTests.cs @@ -0,0 +1,45 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class StatusCodeResultTests + { + [Fact] + public void StatusCodeResult_ExecuteResultSetsResponseStatusCode() + { + // Arrange + var result = new StatusCodeResult(StatusCodes.Status404NotFound); + + var httpContext = GetHttpContext(); + + // Act + result.ExecuteAsync(httpContext); + + // Assert + Assert.Equal(StatusCodes.Status404NotFound, httpContext.Response.StatusCode); + } + + private static IServiceCollection CreateServices() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + return services; + } + + private static HttpContext GetHttpContext() + { + var services = CreateServices(); + + var httpContext = new DefaultHttpContext(); + httpContext.RequestServices = services.BuildServiceProvider(); + + return httpContext; + } + } +} diff --git a/src/Http/Http.Results/test/TestLinkGenerator.cs b/src/Http/Http.Results/test/TestLinkGenerator.cs new file mode 100644 index 000000000000..fbe401fe1bc7 --- /dev/null +++ b/src/Http/Http.Results/test/TestLinkGenerator.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.Http.Result +{ + internal sealed class TestLinkGenerator : LinkGenerator + { + public string Url { get; set; } + + public override string GetPathByAddress(HttpContext httpContext, TAddress address, RouteValueDictionary values, RouteValueDictionary ambientValues = null, PathString? pathBase = null, FragmentString fragment = default, LinkOptions options = null) + { + throw new NotImplementedException(); + } + + public override string GetPathByAddress(TAddress address, RouteValueDictionary values, PathString pathBase = default, FragmentString fragment = default, LinkOptions options = null) + { + throw new NotImplementedException(); + } + + public override string GetUriByAddress(HttpContext httpContext, TAddress address, RouteValueDictionary values, RouteValueDictionary ambientValues = null, string scheme = null, HostString? host = null, PathString? pathBase = null, FragmentString fragment = default, LinkOptions options = null) + => Url; + + public override string GetUriByAddress(TAddress address, RouteValueDictionary values, string scheme, HostString host, PathString pathBase = default, FragmentString fragment = default, LinkOptions options = null) + => Url; + } +} diff --git a/src/Http/Http.Results/test/UnauthorizedResultTests.cs b/src/Http/Http.Results/test/UnauthorizedResultTests.cs new file mode 100644 index 000000000000..5d825bb0d7fc --- /dev/null +++ b/src/Http/Http.Results/test/UnauthorizedResultTests.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class UnauthorizedResultTests + { + [Fact] + public void UnauthorizedResult_InitializesStatusCode() + { + // Arrange & act + var result = new UnauthorizedResult(); + + // Assert + Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode); + } + } +} diff --git a/src/Http/Http.Results/test/UnprocessableEntityObjectResultTests.cs b/src/Http/Http.Results/test/UnprocessableEntityObjectResultTests.cs new file mode 100644 index 000000000000..057a8ce3b3c8 --- /dev/null +++ b/src/Http/Http.Results/test/UnprocessableEntityObjectResultTests.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class UnprocessableEntityObjectResultTests + { + [Fact] + public void UnprocessableEntityObjectResult_SetsStatusCodeAndValue() + { + // Arrange & Act + var obj = new object(); + var result = new UnprocessableEntityObjectResult(obj); + + // Assert + Assert.Equal(StatusCodes.Status422UnprocessableEntity, result.StatusCode); + Assert.Equal(obj, result.Value); + } + } +} diff --git a/src/Http/Http.Results/test/UnprocessableEntityResultTests.cs b/src/Http/Http.Results/test/UnprocessableEntityResultTests.cs new file mode 100644 index 000000000000..07ec867abea2 --- /dev/null +++ b/src/Http/Http.Results/test/UnprocessableEntityResultTests.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class UnprocessableEntityResultTests + { + [Fact] + public void UnprocessableEntityResult_InitializesStatusCode() + { + // Arrange & act + var result = new UnprocessableEntityResult(); + + // Assert + Assert.Equal(StatusCodes.Status422UnprocessableEntity, result.StatusCode); + } + } +} diff --git a/src/Http/Http.Results/test/VirtualFileResultTest.cs b/src/Http/Http.Results/test/VirtualFileResultTest.cs new file mode 100644 index 000000000000..51bb3c703086 --- /dev/null +++ b/src/Http/Http.Results/test/VirtualFileResultTest.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. 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.Threading.Tasks; +using Microsoft.AspNetCore.Internal; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Http.Result +{ + public class VirtualFileResultTest : VirtualFileResultTestBase + { + protected override Task ExecuteAsync(HttpContext httpContext, string path, string contentType, DateTimeOffset? lastModified = null, EntityTagHeaderValue entityTag = null, bool enableRangeProcessing = false) + { + var result = new VirtualFileResult(path, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + EnableRangeProcessing = enableRangeProcessing, + }; + + return result.ExecuteAsync(httpContext); + } + } +} diff --git a/src/Http/HttpAbstractions.slnf b/src/Http/HttpAbstractions.slnf index 93263e734be1..ce6dca91fd62 100644 --- a/src/Http/HttpAbstractions.slnf +++ b/src/Http/HttpAbstractions.slnf @@ -19,6 +19,8 @@ "src\\Http\\Http.Extensions\\src\\Microsoft.AspNetCore.Http.Extensions.csproj", "src\\Http\\Http.Extensions\\test\\Microsoft.AspNetCore.Http.Extensions.Tests.csproj", "src\\Http\\Http.Features\\src\\Microsoft.AspNetCore.Http.Features.csproj", + "src\\Http\\Http.Results\\src\\Microsoft.AspNetCore.Http.Results.csproj", + "src\\Http\\Http.Results\\test\\Microsoft.AspNetCore.Http.Results.Tests.csproj", "src\\Http\\Http\\perf\\Microbenchmarks\\Microsoft.AspNetCore.Http.Microbenchmarks.csproj", "src\\Http\\Http\\src\\Microsoft.AspNetCore.Http.csproj", "src\\Http\\Http\\test\\Microsoft.AspNetCore.Http.Tests.csproj", diff --git a/src/Middleware/StaticFiles/src/LoggerExtensions.cs b/src/Middleware/StaticFiles/src/LoggerExtensions.cs index b2d973bff61b..76be097d0530 100644 --- a/src/Middleware/StaticFiles/src/LoggerExtensions.cs +++ b/src/Middleware/StaticFiles/src/LoggerExtensions.cs @@ -12,19 +12,19 @@ namespace Microsoft.AspNetCore.StaticFiles /// internal static class LoggerExtensions { - private static Action _methodNotSupported; - private static Action _fileServed; - private static Action _pathMismatch; - private static Action _fileTypeNotSupported; - private static Action _fileNotFound; - private static Action _fileNotModified; - private static Action _preconditionFailed; - private static Action _handled; - private static Action _rangeNotSatisfiable; - private static Action _sendingFileRange; - private static Action _copyingFileRange; - private static Action _writeCancelled; - private static Action _endpointMatched; + private static readonly Action _methodNotSupported; + private static readonly Action _fileServed; + private static readonly Action _pathMismatch; + private static readonly Action _fileTypeNotSupported; + private static readonly Action _fileNotFound; + private static readonly Action _fileNotModified; + private static readonly Action _preconditionFailed; + private static readonly Action _handled; + private static readonly Action _rangeNotSatisfiable; + private static readonly Action _sendingFileRange; + private static readonly Action _copyingFileRange; + private static readonly Action _writeCancelled; + private static readonly Action _endpointMatched; static LoggerExtensions() { diff --git a/src/Mvc/Mvc.Core/src/ChallengeResult.cs b/src/Mvc/Mvc.Core/src/ChallengeResult.cs index 9add46ad3828..4b21053cac92 100644 --- a/src/Mvc/Mvc.Core/src/ChallengeResult.cs +++ b/src/Mvc/Mvc.Core/src/ChallengeResult.cs @@ -14,7 +14,7 @@ namespace Microsoft.AspNetCore.Mvc /// /// An that on execution invokes . /// - public class ChallengeResult : ActionResult, IResult + public class ChallengeResult : ActionResult { /// /// Initializes a new instance of . @@ -91,24 +91,14 @@ public ChallengeResult(IList authenticationSchemes, AuthenticationProper public AuthenticationProperties? Properties { get; set; } /// - public override Task ExecuteResultAsync(ActionContext context) + public override async Task ExecuteResultAsync(ActionContext context) { if (context == null) { throw new ArgumentNullException(nameof(context)); } - return ExecuteAsync(context.HttpContext); - } - - /// - Task IResult.ExecuteAsync(HttpContext httpContext) - { - return ExecuteAsync(httpContext); - } - - private async Task ExecuteAsync(HttpContext httpContext) - { + var httpContext = context.HttpContext; var loggerFactory = httpContext.RequestServices.GetRequiredService(); var logger = loggerFactory.CreateLogger(); diff --git a/src/Mvc/Mvc.Core/src/ContentResult.cs b/src/Mvc/Mvc.Core/src/ContentResult.cs index c4d8158ac7b2..e1d86fc1f03f 100644 --- a/src/Mvc/Mvc.Core/src/ContentResult.cs +++ b/src/Mvc/Mvc.Core/src/ContentResult.cs @@ -3,22 +3,16 @@ using System; using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Mvc { /// /// A that when executed will produce a response with content. /// - public class ContentResult : ActionResult, IResult, IStatusCodeActionResult + public class ContentResult : ActionResult, IStatusCodeActionResult { - private const string DefaultContentType = "text/plain; charset=utf-8"; - /// /// Gets or set the content representing the body of the response. /// @@ -45,45 +39,5 @@ public override Task ExecuteResultAsync(ActionContext context) var executor = context.HttpContext.RequestServices.GetRequiredService>(); return executor.ExecuteAsync(context, this); } - - /// - /// Writes the content to the HTTP response. - /// - /// The for the current request. - /// A task that represents the asynchronous execute operation. - async Task IResult.ExecuteAsync(HttpContext httpContext) - { - if (httpContext == null) - { - throw new ArgumentNullException(nameof(httpContext)); - } - - var response = httpContext.Response; - - ResponseContentTypeHelper.ResolveContentTypeAndEncoding( - ContentType, - response.ContentType, - DefaultContentType, - out var resolvedContentType, - out var resolvedContentTypeEncoding); - - response.ContentType = resolvedContentType; - - if (StatusCode != null) - { - response.StatusCode = StatusCode.Value; - } - - var factory = httpContext.RequestServices.GetRequiredService(); - var logger = factory.CreateLogger(); - - logger.ContentResultExecuting(resolvedContentType); - - if (Content != null) - { - response.ContentLength = resolvedContentTypeEncoding.GetByteCount(Content); - await response.WriteAsync(Content, resolvedContentTypeEncoding); - } - } } } diff --git a/src/Mvc/Mvc.Core/src/FileContentResult.cs b/src/Mvc/Mvc.Core/src/FileContentResult.cs index f9bc761110b6..7fcb8729112f 100644 --- a/src/Mvc/Mvc.Core/src/FileContentResult.cs +++ b/src/Mvc/Mvc.Core/src/FileContentResult.cs @@ -3,12 +3,9 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.Mvc @@ -17,7 +14,7 @@ namespace Microsoft.AspNetCore.Mvc /// Represents an that when executed will /// write a binary file to the response. /// - public class FileContentResult : FileResult, IResult + public class FileContentResult : FileResult { private byte[] _fileContents; @@ -80,43 +77,5 @@ public override Task ExecuteResultAsync(ActionContext context) var executor = context.HttpContext.RequestServices.GetRequiredService>(); return executor.ExecuteAsync(context, this); } - - Task IResult.ExecuteAsync(HttpContext httpContext) - { - if (httpContext == null) - { - throw new ArgumentNullException(nameof(httpContext)); - } - - var loggerFactory = httpContext.RequestServices.GetRequiredService(); - var logger = loggerFactory.CreateLogger(); - - var (range, rangeLength, serveBody) = FileResultExecutorBase.SetHeadersAndLog( - httpContext, - this, - FileContents.Length, - EnableRangeProcessing, - LastModified, - EntityTag, - logger); - - if (!serveBody) - { - return Task.CompletedTask; - } - - if (range != null && rangeLength == 0) - { - return Task.CompletedTask; - } - - if (range != null) - { - logger.WritingRangeToBody(); - } - - var fileContentStream = new MemoryStream(FileContents); - return FileResultExecutorBase.WriteFileAsyncInternal(httpContext, fileContentStream, range, rangeLength); - } } } diff --git a/src/Mvc/Mvc.Core/src/ForbidResult.cs b/src/Mvc/Mvc.Core/src/ForbidResult.cs index de269350ff37..5418f7685edd 100644 --- a/src/Mvc/Mvc.Core/src/ForbidResult.cs +++ b/src/Mvc/Mvc.Core/src/ForbidResult.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -14,7 +13,7 @@ namespace Microsoft.AspNetCore.Mvc /// /// An that on execution invokes . /// - public class ForbidResult : ActionResult, IResult + public class ForbidResult : ActionResult { /// /// Initializes a new instance of . @@ -91,24 +90,15 @@ public ForbidResult(IList authenticationSchemes, AuthenticationPropertie public AuthenticationProperties? Properties { get; set; } /// - public override Task ExecuteResultAsync(ActionContext context) + public override async Task ExecuteResultAsync(ActionContext context) { if (context == null) { throw new ArgumentNullException(nameof(context)); } - return ExecuteAsync(context.HttpContext); - } - - /// - Task IResult.ExecuteAsync(HttpContext httpContext) - { - return ExecuteAsync(httpContext); - } + var httpContext = context.HttpContext; - private async Task ExecuteAsync(HttpContext httpContext) - { var loggerFactory = httpContext.RequestServices.GetRequiredService(); var logger = loggerFactory.CreateLogger(); diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/ContentResultExecutor.cs b/src/Mvc/Mvc.Core/src/Infrastructure/ContentResultExecutor.cs index 865c8b2d377a..6dd8c0b19ae6 100644 --- a/src/Mvc/Mvc.Core/src/Infrastructure/ContentResultExecutor.cs +++ b/src/Mvc/Mvc.Core/src/Infrastructure/ContentResultExecutor.cs @@ -4,7 +4,9 @@ #nullable enable using System; +using System.Text; using System.Threading.Tasks; +using Microsoft.AspNetCore.Internal; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.Logging; @@ -48,7 +50,8 @@ public virtual async Task ExecuteAsync(ActionContext context, ContentResult resu ResponseContentTypeHelper.ResolveContentTypeAndEncoding( result.ContentType, response.ContentType, - DefaultContentType, + (DefaultContentType, Encoding.UTF8), + MediaType.GetEncoding, out var resolvedContentType, out var resolvedContentTypeEncoding); diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/FileResultExecutorBase.cs b/src/Mvc/Mvc.Core/src/Infrastructure/FileResultExecutorBase.cs index 72dca6e2c2b5..3d0e63e6d573 100644 --- a/src/Mvc/Mvc.Core/src/Infrastructure/FileResultExecutorBase.cs +++ b/src/Mvc/Mvc.Core/src/Infrastructure/FileResultExecutorBase.cs @@ -1,15 +1,10 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -#nullable enable - using System; -using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.AspNetCore.Http.Headers; using Microsoft.AspNetCore.Internal; using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; @@ -17,12 +12,10 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure { /// - /// Base class for a file result. + /// Base class for executing a file result. /// public class FileResultExecutorBase { - private const string AcceptRangeHeaderValue = "bytes"; - /// /// The buffer size: 64 * 1024. /// @@ -68,330 +61,16 @@ protected virtual (RangeItemHeaderValue? range, long rangeLength, bool serveBody DateTimeOffset? lastModified = null, EntityTagHeaderValue? etag = null) { - return SetHeadersAndLog( - context.HttpContext, - result, - fileLength, - enableRangeProcessing, - lastModified, - etag, - Logger); - } - - internal static (RangeItemHeaderValue? range, long rangeLength, bool serveBody) SetHeadersAndLog( - HttpContext httpContext, - FileResult result, - long? fileLength, - bool enableRangeProcessing, - DateTimeOffset? lastModified, - EntityTagHeaderValue? etag, - ILogger logger) - { - if (httpContext == null) - { - throw new ArgumentNullException(nameof(httpContext)); - } - if (result == null) - { - throw new ArgumentNullException(nameof(result)); - } - - var request = httpContext.Request; - var httpRequestHeaders = request.GetTypedHeaders(); - - // Since the 'Last-Modified' and other similar http date headers are rounded down to whole seconds, - // round down current file's last modified to whole seconds for correct comparison. - if (lastModified.HasValue) - { - lastModified = RoundDownToWholeSeconds(lastModified.Value); - } - - var preconditionState = GetPreconditionState(httpRequestHeaders, lastModified, etag, logger); - - var response = httpContext.Response; - SetLastModifiedAndEtagHeaders(response, lastModified, etag); - - // Short circuit if the preconditional headers process to 304 (NotModified) or 412 (PreconditionFailed) - if (preconditionState == PreconditionState.NotModified) - { - response.StatusCode = StatusCodes.Status304NotModified; - return (range: null, rangeLength: 0, serveBody: false); - } - else if (preconditionState == PreconditionState.PreconditionFailed) - { - response.StatusCode = StatusCodes.Status412PreconditionFailed; - return (range: null, rangeLength: 0, serveBody: false); - } - - SetContentType(httpContext, result); - SetContentDispositionHeader(httpContext, result); - - if (fileLength.HasValue) - { - // Assuming the request is not a range request, and the response body is not empty, the Content-Length header is set to - // the length of the entire file. - // If the request is a valid range request, this header is overwritten with the length of the range as part of the - // range processing (see method SetContentLength). - - response.ContentLength = fileLength.Value; - - // Handle range request - if (enableRangeProcessing) - { - SetAcceptRangeHeader(response); - - // If the request method is HEAD or GET, PreconditionState is Unspecified or ShouldProcess, and IfRange header is valid, - // range should be processed and Range headers should be set - if ((HttpMethods.IsHead(request.Method) || HttpMethods.IsGet(request.Method)) - && (preconditionState == PreconditionState.Unspecified || preconditionState == PreconditionState.ShouldProcess) - && (IfRangeValid(httpRequestHeaders, lastModified, etag, logger))) - { - return SetRangeHeaders(httpContext, httpRequestHeaders, fileLength.Value, logger); - } - } - else - { - logger.NotEnabledForRangeProcessing(); - } - } - - return (range: null, rangeLength: 0, serveBody: !HttpMethods.IsHead(request.Method)); - } - - private static void SetContentType(HttpContext httpContext, FileResult result) - { - var response = httpContext.Response; - response.ContentType = result.ContentType; - } - - private static void SetContentDispositionHeader(HttpContext httpContext, FileResult result) - { - if (!string.IsNullOrEmpty(result.FileDownloadName)) - { - // From RFC 2183, Sec. 2.3: - // The sender may want to suggest a filename to be used if the entity is - // detached and stored in a separate file. If the receiving MUA writes - // the entity to a file, the suggested filename should be used as a - // basis for the actual filename, where possible. - var contentDisposition = new ContentDispositionHeaderValue("attachment"); - contentDisposition.SetHttpFileName(result.FileDownloadName); - httpContext.Response.Headers.ContentDisposition = contentDisposition.ToString(); - } - } - - private static void SetLastModifiedAndEtagHeaders(HttpResponse response, DateTimeOffset? lastModified, EntityTagHeaderValue? etag) - { - var httpResponseHeaders = response.GetTypedHeaders(); - if (lastModified.HasValue) - { - httpResponseHeaders.LastModified = lastModified; - } - if (etag != null) - { - httpResponseHeaders.ETag = etag; - } - } - - private static void SetAcceptRangeHeader(HttpResponse response) - { - response.Headers.AcceptRanges = AcceptRangeHeaderValue; - } - - internal static bool IfRangeValid( - RequestHeaders httpRequestHeaders, - DateTimeOffset? lastModified, - EntityTagHeaderValue? etag, - ILogger logger) - { - // 14.27 If-Range - var ifRange = httpRequestHeaders.IfRange; - if (ifRange != null) - { - // If the validator given in the If-Range header field matches the - // current validator for the selected representation of the target - // resource, then the server SHOULD process the Range header field as - // requested. If the validator does not match, the server MUST ignore - // the Range header field. - if (ifRange.LastModified.HasValue) - { - if (lastModified.HasValue && lastModified > ifRange.LastModified) - { - logger.IfRangeLastModifiedPreconditionFailed(lastModified, ifRange.LastModified); - return false; - } - } - else if (etag != null && ifRange.EntityTag != null && !ifRange.EntityTag.Compare(etag, useStrongComparison: true)) - { - logger.IfRangeETagPreconditionFailed(etag, ifRange.EntityTag); - return false; - } - } - - return true; - } - - // Internal for testing - internal static PreconditionState GetPreconditionState( - RequestHeaders httpRequestHeaders, - DateTimeOffset? lastModified, - EntityTagHeaderValue? etag, - ILogger logger) - { - var ifMatchState = PreconditionState.Unspecified; - var ifNoneMatchState = PreconditionState.Unspecified; - var ifModifiedSinceState = PreconditionState.Unspecified; - var ifUnmodifiedSinceState = PreconditionState.Unspecified; - - // 14.24 If-Match - var ifMatch = httpRequestHeaders.IfMatch; - if (etag != null) - { - ifMatchState = GetEtagMatchState( - useStrongComparison: true, - etagHeader: ifMatch, - etag: etag, - matchFoundState: PreconditionState.ShouldProcess, - matchNotFoundState: PreconditionState.PreconditionFailed); - - if (ifMatchState == PreconditionState.PreconditionFailed) - { - logger.IfMatchPreconditionFailed(etag); - } - } - - // 14.26 If-None-Match - var ifNoneMatch = httpRequestHeaders.IfNoneMatch; - if (etag != null) - { - ifNoneMatchState = GetEtagMatchState( - useStrongComparison: false, - etagHeader: ifNoneMatch, - etag: etag, - matchFoundState: PreconditionState.NotModified, - matchNotFoundState: PreconditionState.ShouldProcess); - } - - var now = RoundDownToWholeSeconds(DateTimeOffset.UtcNow); - - // 14.25 If-Modified-Since - var ifModifiedSince = httpRequestHeaders.IfModifiedSince; - if (lastModified.HasValue && ifModifiedSince.HasValue && ifModifiedSince <= now) - { - var modified = ifModifiedSince < lastModified; - ifModifiedSinceState = modified ? PreconditionState.ShouldProcess : PreconditionState.NotModified; - } - - // 14.28 If-Unmodified-Since - var ifUnmodifiedSince = httpRequestHeaders.IfUnmodifiedSince; - if (lastModified.HasValue && ifUnmodifiedSince.HasValue && ifUnmodifiedSince <= now) - { - var unmodified = ifUnmodifiedSince >= lastModified; - ifUnmodifiedSinceState = unmodified ? PreconditionState.ShouldProcess : PreconditionState.PreconditionFailed; - - if (ifUnmodifiedSinceState == PreconditionState.PreconditionFailed) - { - logger.IfUnmodifiedSincePreconditionFailed(lastModified, ifUnmodifiedSince); - } - } - - var state = GetMaxPreconditionState(ifMatchState, ifNoneMatchState, ifModifiedSinceState, ifUnmodifiedSinceState); - return state; - } - - private static PreconditionState GetEtagMatchState( - bool useStrongComparison, - IList etagHeader, - EntityTagHeaderValue etag, - PreconditionState matchFoundState, - PreconditionState matchNotFoundState) - { - if (etagHeader?.Count > 0) + var fileResultInfo = new FileResultInfo { - var state = matchNotFoundState; - foreach (var entityTag in etagHeader) - { - if (entityTag.Equals(EntityTagHeaderValue.Any) || entityTag.Compare(etag, useStrongComparison)) - { - state = matchFoundState; - break; - } - } - - return state; - } + ContentType = result.ContentType, + EnableRangeProcessing = result.EnableRangeProcessing, + EntityTag = result.EntityTag, + FileDownloadName = result.FileDownloadName, + LastModified = result.LastModified, + }; - return PreconditionState.Unspecified; - } - - private static PreconditionState GetMaxPreconditionState(params PreconditionState[] states) - { - var max = PreconditionState.Unspecified; - for (var i = 0; i < states.Length; i++) - { - if (states[i] > max) - { - max = states[i]; - } - } - - return max; - } - - private static (RangeItemHeaderValue? range, long rangeLength, bool serveBody) SetRangeHeaders( - HttpContext httpContext, - RequestHeaders httpRequestHeaders, - long fileLength, - ILogger logger) - { - var response = httpContext.Response; - var httpResponseHeaders = response.GetTypedHeaders(); - var serveBody = !HttpMethods.IsHead(httpContext.Request.Method); - - // Range may be null for empty range header, invalid ranges, parsing errors, multiple ranges - // and when the file length is zero. - var (isRangeRequest, range) = RangeHelper.ParseRange( - httpContext, - httpRequestHeaders, - fileLength, - logger); - - if (!isRangeRequest) - { - return (range: null, rangeLength: 0, serveBody); - } - - // Requested range is not satisfiable - if (range == null) - { - // 14.16 Content-Range - A server sending a response with status code 416 (Requested range not satisfiable) - // SHOULD include a Content-Range field with a byte-range-resp-spec of "*". The instance-length specifies - // the current length of the selected resource. e.g. */length - response.StatusCode = StatusCodes.Status416RangeNotSatisfiable; - httpResponseHeaders.ContentRange = new ContentRangeHeaderValue(fileLength); - response.ContentLength = 0; - - return (range: null, rangeLength: 0, serveBody: false); - } - - response.StatusCode = StatusCodes.Status206PartialContent; - httpResponseHeaders.ContentRange = new ContentRangeHeaderValue( - range.From!.Value, - range.To!.Value, - fileLength); - - // Overwrite the Content-Length header for valid range requests with the range length. - var rangeLength = SetContentLength(response, range); - - return (range, rangeLength, serveBody); - } - - private static long SetContentLength(HttpResponse response, RangeItemHeaderValue range) - { - var start = range.From!.Value; - var end = range.To!.Value; - var length = end - start + 1; - response.ContentLength = length; - return length; + return FileResultHelper.SetHeadersAndLog(context.HttpContext, fileResultInfo, fileLength, enableRangeProcessing, lastModified, etag, Logger); } /// @@ -420,39 +99,7 @@ protected static ILogger CreateLogger(ILoggerFactory factory) /// The async task. protected static async Task WriteFileAsync(HttpContext context, Stream fileStream, RangeItemHeaderValue? range, long rangeLength) { - await WriteFileAsyncInternal(context, fileStream, range, rangeLength); - } - - internal static async Task WriteFileAsyncInternal(HttpContext context, Stream fileStream, RangeItemHeaderValue? range, long rangeLength) - { - var outputStream = context.Response.Body; - using (fileStream) - { - try - { - if (range == null) - { - await StreamCopyOperation.CopyToAsync(fileStream, outputStream, count: null, bufferSize: BufferSize, cancel: context.RequestAborted); - } - else - { - fileStream.Seek(range.From!.Value, SeekOrigin.Begin); - await StreamCopyOperation.CopyToAsync(fileStream, outputStream, rangeLength, BufferSize, context.RequestAborted); - } - } - catch (OperationCanceledException) - { - // Don't throw this exception, it's most likely caused by the client disconnecting. - // However, if it was cancelled for any other reason we need to prevent empty responses. - context.Abort(); - } - } - } - - private static DateTimeOffset RoundDownToWholeSeconds(DateTimeOffset dateTimeOffset) - { - var ticksToRemove = dateTimeOffset.Ticks % TimeSpan.TicksPerSecond; - return dateTimeOffset.Subtract(TimeSpan.FromTicks(ticksToRemove)); + await FileResultHelper.WriteFileAsync(context, fileStream, range, rangeLength); } } } diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs b/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs index 5c7c6e8b5b7e..b8d202148e6e 100644 --- a/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs +++ b/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs @@ -7,6 +7,7 @@ using System.Text; using System.Text.Json; using System.Threading.Tasks; +using Microsoft.AspNetCore.Internal; using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.Logging; @@ -55,7 +56,8 @@ public async Task ExecuteAsync(ActionContext context, JsonResult result) ResponseContentTypeHelper.ResolveContentTypeAndEncoding( result.ContentType, response.ContentType, - DefaultContentType, + (DefaultContentType, Encoding.UTF8), + MediaType.GetEncoding, out var resolvedContentType, out var resolvedContentTypeEncoding); diff --git a/src/Mvc/Mvc.Core/src/JsonResult.cs b/src/Mvc/Mvc.Core/src/JsonResult.cs index a31e55e61d3f..e752b995a7b5 100644 --- a/src/Mvc/Mvc.Core/src/JsonResult.cs +++ b/src/Mvc/Mvc.Core/src/JsonResult.cs @@ -4,7 +4,6 @@ using System; using System.Text.Json; using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection; @@ -13,7 +12,7 @@ namespace Microsoft.AspNetCore.Mvc /// /// An action result which formats the given object as JSON. /// - public class JsonResult : ActionResult, IResult, IStatusCodeActionResult + public class JsonResult : ActionResult, IStatusCodeActionResult { /// /// Creates a new with the given . @@ -81,15 +80,5 @@ public override Task ExecuteResultAsync(ActionContext context) var executor = services.GetRequiredService>(); return executor.ExecuteAsync(context, this); } - - /// - /// Write the result as JSON to the HTTP response. - /// - /// The for the current request. - /// A task that represents the asynchronous execute operation. - Task IResult.ExecuteAsync(HttpContext httpContext) - { - return httpContext.Response.WriteAsJsonAsync(Value); - } } } diff --git a/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj b/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj index 745f799848f5..28887a6351a8 100644 --- a/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj +++ b/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj @@ -26,6 +26,8 @@ Microsoft.AspNetCore.Mvc.RouteAttribute + + diff --git a/src/Mvc/Mvc.Core/src/MvcCoreLoggerExtensions.cs b/src/Mvc/Mvc.Core/src/MvcCoreLoggerExtensions.cs index 8c3a0a27651f..d510b5f737be 100644 --- a/src/Mvc/Mvc.Core/src/MvcCoreLoggerExtensions.cs +++ b/src/Mvc/Mvc.Core/src/MvcCoreLoggerExtensions.cs @@ -322,7 +322,7 @@ static MvcCoreLoggerExtensions() _httpStatusCodeResultExecuting = LoggerMessage.Define( LogLevel.Information, new EventId(1, "HttpStatusCodeResultExecuting"), - "Executing HttpStatusCodeResult, setting HTTP status code {StatusCode}"); + "Executing StatusCodeResult, setting HTTP status code {StatusCode}"); _localRedirectResultExecuting = LoggerMessage.Define( LogLevel.Information, diff --git a/src/Mvc/Mvc.Core/src/PhysicalFileResult.cs b/src/Mvc/Mvc.Core/src/PhysicalFileResult.cs index 9734ddbacdfe..def6a2b65408 100644 --- a/src/Mvc/Mvc.Core/src/PhysicalFileResult.cs +++ b/src/Mvc/Mvc.Core/src/PhysicalFileResult.cs @@ -3,13 +3,9 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.Mvc @@ -18,7 +14,7 @@ namespace Microsoft.AspNetCore.Mvc /// A on execution will write a file from disk to the response /// using mechanisms provided by the host. /// - public class PhysicalFileResult : FileResult, IResult + public class PhysicalFileResult : FileResult { private string _fileName; @@ -70,51 +66,5 @@ public override Task ExecuteResultAsync(ActionContext context) var executor = context.HttpContext.RequestServices.GetRequiredService>(); return executor.ExecuteAsync(context, this); } - - Task IResult.ExecuteAsync(HttpContext httpContext) - { - if (httpContext == null) - { - throw new ArgumentNullException(nameof(httpContext)); - } - - var fileInfo = new FileInfo(FileName); - if (!fileInfo.Exists) - { - throw new FileNotFoundException( - Resources.FormatFileResult_InvalidPath(FileName), FileName); - } - - return ExecuteAsyncInternal(httpContext, this, fileInfo.LastWriteTimeUtc, fileInfo.Length); - } - - internal static Task ExecuteAsyncInternal( - HttpContext httpContext, - PhysicalFileResult result, - DateTimeOffset fileLastModified, - long fileLength) - { - var loggerFactory = httpContext.RequestServices.GetRequiredService(); - var logger = loggerFactory.CreateLogger(); - - logger.ExecutingFileResult(result, result.FileName); - - var lastModified = result.LastModified ?? fileLastModified; - var (range, rangeLength, serveBody) = FileResultExecutorBase.SetHeadersAndLog( - httpContext, - result, - fileLength, - result.EnableRangeProcessing, - lastModified, - result.EntityTag, - logger); - - if (serveBody) - { - return PhysicalFileResultExecutor.WriteFileAsyncInternal(httpContext, result, range, rangeLength, logger); - } - - return Task.CompletedTask; - } } } diff --git a/src/Mvc/Mvc.Core/src/RedirectResult.cs b/src/Mvc/Mvc.Core/src/RedirectResult.cs index 8ce2beb6f6b4..5077bf9285e4 100644 --- a/src/Mvc/Mvc.Core/src/RedirectResult.cs +++ b/src/Mvc/Mvc.Core/src/RedirectResult.cs @@ -18,7 +18,7 @@ namespace Microsoft.AspNetCore.Mvc /// An that returns a Found (302), Moved Permanently (301), Temporary Redirect (307), /// or Permanent Redirect (308) response with a Location header to the supplied URL. /// - public class RedirectResult : ActionResult, IResult, IKeepTempDataResult + public class RedirectResult : ActionResult, IKeepTempDataResult { private string _url; @@ -115,36 +115,5 @@ public override Task ExecuteResultAsync(ActionContext context) var executor = context.HttpContext.RequestServices.GetRequiredService>(); return executor.ExecuteAsync(context, this); } - - /// - Task IResult.ExecuteAsync(HttpContext httpContext) - { - if (httpContext == null) - { - throw new ArgumentNullException(nameof(httpContext)); - } - - var loggerFactory = httpContext.RequestServices.GetRequiredService(); - var logger = loggerFactory.CreateLogger(); - - // IsLocalUrl is called to handle URLs starting with '~/'. - var destinationUrl = UrlHelperBase.CheckIsLocalUrl(_url) ? UrlHelperBase.Content(httpContext, _url) : _url; - - logger.RedirectResultExecuting(destinationUrl); - - if (PreserveMethod) - { - httpContext.Response.StatusCode = Permanent - ? StatusCodes.Status308PermanentRedirect - : StatusCodes.Status307TemporaryRedirect; - httpContext.Response.Headers.Location = destinationUrl; - } - else - { - httpContext.Response.Redirect(destinationUrl, Permanent); - } - - return Task.CompletedTask; - } } } diff --git a/src/Mvc/Mvc.Core/src/Resources.resx b/src/Mvc/Mvc.Core/src/Resources.resx index a1e872c5be9b..44b27197e359 100644 --- a/src/Mvc/Mvc.Core/src/Resources.resx +++ b/src/Mvc/Mvc.Core/src/Resources.resx @@ -237,7 +237,7 @@ 0 is the newline - 1 is a newline separate list of action display names - Could not find file: {0} + Could not find file: {0}. {0} is the value for the provided path diff --git a/src/Mvc/Mvc.Core/src/SignInResult.cs b/src/Mvc/Mvc.Core/src/SignInResult.cs index d890868a1dfd..185afa671680 100644 --- a/src/Mvc/Mvc.Core/src/SignInResult.cs +++ b/src/Mvc/Mvc.Core/src/SignInResult.cs @@ -5,8 +5,6 @@ using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Core; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -15,7 +13,7 @@ namespace Microsoft.AspNetCore.Mvc /// /// An that on execution invokes . /// - public class SignInResult : ActionResult, IResult + public class SignInResult : ActionResult { /// /// Initializes a new instance of with the @@ -86,17 +84,7 @@ public override Task ExecuteResultAsync(ActionContext context) throw new ArgumentNullException(nameof(context)); } - return ExecuteAsync(context.HttpContext); - } - - /// - Task IResult.ExecuteAsync(HttpContext httpContext) - { - return ExecuteAsync(httpContext); - } - - private Task ExecuteAsync(HttpContext httpContext) - { + var httpContext = context.HttpContext; var loggerFactory = httpContext.RequestServices.GetRequiredService(); var logger = loggerFactory.CreateLogger(); diff --git a/src/Mvc/Mvc.Core/src/VirtualFileResult.cs b/src/Mvc/Mvc.Core/src/VirtualFileResult.cs index ac95c84a07eb..ff83cbc74d04 100644 --- a/src/Mvc/Mvc.Core/src/VirtualFileResult.cs +++ b/src/Mvc/Mvc.Core/src/VirtualFileResult.cs @@ -20,7 +20,7 @@ namespace Microsoft.AspNetCore.Mvc /// A that on execution writes the file specified using a virtual path to the response /// using mechanisms provided by the host. /// - public class VirtualFileResult : FileResult, IResult + public class VirtualFileResult : FileResult { private string _fileName; @@ -74,42 +74,5 @@ public override Task ExecuteResultAsync(ActionContext context) var executor = context.HttpContext.RequestServices.GetRequiredService>(); return executor.ExecuteAsync(context, this); } - - Task IResult.ExecuteAsync(HttpContext httpContext) - { - if (httpContext == null) - { - throw new ArgumentNullException(nameof(httpContext)); - } - - var hostingEnvironment = httpContext.RequestServices.GetRequiredService(); - - var fileInfo = VirtualFileResultExecutor.GetFileInformation(this, hostingEnvironment); - if (!fileInfo.Exists) - { - throw new FileNotFoundException( - Resources.FormatFileResult_InvalidPath(FileName), FileName); - } - - var loggerFactory = httpContext.RequestServices.GetRequiredService(); - var logger = loggerFactory.CreateLogger(); - - var lastModified = LastModified ?? fileInfo.LastModified; - var (range, rangeLength, serveBody) = FileResultExecutorBase.SetHeadersAndLog( - httpContext, - this, - fileInfo.Length, - EnableRangeProcessing, - lastModified, - EntityTag, - logger); - - if (serveBody) - { - return VirtualFileResultExecutor.WriteFileAsyncInternal(httpContext, fileInfo, range, rangeLength, logger); - } - - return Task.CompletedTask; - } } } diff --git a/src/Mvc/Mvc.Core/test/AcceptedResultTests.cs b/src/Mvc/Mvc.Core/test/AcceptedResultTests.cs index da714c3f50dc..4dcb7fadd072 100644 --- a/src/Mvc/Mvc.Core/test/AcceptedResultTests.cs +++ b/src/Mvc/Mvc.Core/test/AcceptedResultTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; @@ -14,7 +14,7 @@ using Moq; using Xunit; -namespace Microsoft.AspNetCore.Mvc.Core.Test +namespace Microsoft.AspNetCore.Mvc { public class AcceptedResultTests { diff --git a/src/Mvc/Mvc.Core/test/ChallengeResultTest.cs b/src/Mvc/Mvc.Core/test/ChallengeResultTest.cs index d36fdef0cb4f..2de96e8c8c9b 100644 --- a/src/Mvc/Mvc.Core/test/ChallengeResultTest.cs +++ b/src/Mvc/Mvc.Core/test/ChallengeResultTest.cs @@ -67,43 +67,6 @@ public async Task ChallengeResult_ExecuteResultAsync_NoSchemes() auth.Verify(c => c.ChallengeAsync(httpContext.Object, null, null), Times.Exactly(1)); } - [Fact] - public async Task ChallengeResult_ExecuteAsync() - { - // Arrange - var result = new ChallengeResult("", null); - - var auth = new Mock(); - - var httpContext = new Mock(); - httpContext.SetupGet(c => c.RequestServices) - .Returns(CreateServices().AddSingleton(auth.Object).BuildServiceProvider()); - - // Act - await ((IResult)result).ExecuteAsync(httpContext.Object); - - // Assert - auth.Verify(c => c.ChallengeAsync(httpContext.Object, "", null), Times.Exactly(1)); - } - - [Fact] - public async Task ChallengeResult_ExecuteAsync_NoSchemes() - { - // Arrange - var result = new ChallengeResult(new string[] { }, null); - - var auth = new Mock(); - var httpContext = new Mock(); - httpContext.SetupGet(c => c.RequestServices) - .Returns(CreateServices().AddSingleton(auth.Object).BuildServiceProvider()); - - // Act - await ((IResult)result).ExecuteAsync(httpContext.Object); - - // Assert - auth.Verify(c => c.ChallengeAsync(httpContext.Object, null, null), Times.Exactly(1)); - } - private static IServiceCollection CreateServices() { var services = new ServiceCollection(); diff --git a/src/Mvc/Mvc.Core/test/ContentResultTest.cs b/src/Mvc/Mvc.Core/test/ContentResultTest.cs index db4e0c66c803..ebc7e7b34578 100644 --- a/src/Mvc/Mvc.Core/test/ContentResultTest.cs +++ b/src/Mvc/Mvc.Core/test/ContentResultTest.cs @@ -45,28 +45,6 @@ public async Task ContentResult_ExecuteResultAsync_Response_NullContent_SetsCont MediaTypeAssert.Equal("text/plain; charset=utf-16", httpContext.Response.ContentType); } - [Fact] - public async Task ContentResult_ExecuteAsync_Response_NullContent_SetsContentTypeAndEncoding() - { - // Arrange - var contentResult = new ContentResult - { - Content = null, - ContentType = new MediaTypeHeaderValue("text/plain") - { - Encoding = Encoding.Unicode - }.ToString() - }; - var httpContext = GetHttpContext(); - var actionContext = GetActionContext(httpContext); - - // Act - await ((IResult)contentResult).ExecuteAsync(httpContext); - - // Assert - MediaTypeAssert.Equal("text/plain; charset=utf-16", httpContext.Response.ContentType); - } - public static TheoryData ContentResultContentTypeData { get @@ -165,36 +143,6 @@ public async Task ContentResult_ExecuteResultAsync_SetContentTypeAndEncoding_OnR Assert.Equal(expectedContentData.Length, httpContext.Response.ContentLength); } - [Theory] - [MemberData(nameof(ContentResultContentTypeData))] - public async Task ContentResult_ExecuteAsync_SetContentTypeAndEncoding_OnResponse( - MediaTypeHeaderValue contentType, - string content, - string responseContentType, - string expectedContentType, - byte[] expectedContentData) - { - // Arrange - var contentResult = new ContentResult - { - Content = content, - ContentType = contentType?.ToString() - }; - var httpContext = GetHttpContext(); - var memoryStream = new MemoryStream(); - httpContext.Response.Body = memoryStream; - httpContext.Response.ContentType = responseContentType; - - // Act - await ((IResult)contentResult).ExecuteAsync(httpContext); - - // Assert - var finalResponseContentType = httpContext.Response.ContentType; - Assert.Equal(expectedContentType, finalResponseContentType); - Assert.Equal(expectedContentData, memoryStream.ToArray()); - Assert.Equal(expectedContentData.Length, httpContext.Response.ContentLength); - } - public static TheoryData ContentResult_WritesDataCorrectly_ForDifferentContentSizesData { get @@ -298,31 +246,6 @@ public async Task ContentResult_WritesDataCorrectly_ForDifferentContentSizes(str Assert.Equal(content, actualContent); } - [Theory] - [MemberData(nameof(ContentResult_WritesDataCorrectly_ForDifferentContentSizesData))] - public async Task ContentResult_ExecuteAsync_WritesDataCorrectly_ForDifferentContentSizes(string content, string contentType) - { - // Arrange - var contentResult = new ContentResult - { - Content = content, - ContentType = contentType - }; - var httpContext = GetHttpContext(); - var memoryStream = new MemoryStream(); - httpContext.Response.Body = memoryStream; - var encoding = MediaTypeHeaderValue.Parse(contentType).Encoding; - - // Act - await ((IResult)contentResult).ExecuteAsync(httpContext); - - // Assert - memoryStream.Seek(0, SeekOrigin.Begin); - var streamReader = new StreamReader(memoryStream, encoding); - var actualContent = await streamReader.ReadToEndAsync(); - Assert.Equal(content, actualContent); - } - private static ActionContext GetActionContext(HttpContext httpContext) { var routeData = new RouteData(); diff --git a/src/Mvc/Mvc.Core/test/ControllerBaseTest.cs b/src/Mvc/Mvc.Core/test/ControllerBaseTest.cs index e29e773bbaba..f4cf6bf3e2f1 100644 --- a/src/Mvc/Mvc.Core/test/ControllerBaseTest.cs +++ b/src/Mvc/Mvc.Core/test/ControllerBaseTest.cs @@ -10,7 +10,6 @@ using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.DataAnnotations; using Microsoft.AspNetCore.Mvc.Formatters; @@ -27,7 +26,7 @@ using Moq; using Xunit; -namespace Microsoft.AspNetCore.Mvc.Core.Test +namespace Microsoft.AspNetCore.Mvc { public class ControllerBaseTest { diff --git a/src/Mvc/Mvc.Core/test/FileContentActionResultTest.cs b/src/Mvc/Mvc.Core/test/FileContentActionResultTest.cs deleted file mode 100644 index a8e91fddd761..000000000000 --- a/src/Mvc/Mvc.Core/test/FileContentActionResultTest.cs +++ /dev/null @@ -1,155 +0,0 @@ -// Copyright (c) .NET Foundation. 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.Threading.Tasks; -using Microsoft.Net.Http.Headers; -using Xunit; - -namespace Microsoft.AspNetCore.Mvc -{ - public class FileContentActionResultTest - { - [Fact] - public void Constructor_SetsFileContents() - { - // Arrange - var fileContents = new byte[0]; - - // Act - var result = new FileContentResult(fileContents, "text/plain"); - - // Assert - Assert.Same(fileContents, result.FileContents); - } - - [Fact] - public void Constructor_SetsContentTypeAndParameters() - { - // Arrange - var fileContents = new byte[0]; - var contentType = "text/plain; charset=us-ascii; p1=p1-value"; - var expectedMediaType = contentType; - - // Act - var result = new FileContentResult(fileContents, contentType); - - // Assert - Assert.Same(fileContents, result.FileContents); - MediaTypeAssert.Equal(expectedMediaType, result.ContentType); - } - - [Fact] - public void Constructor_SetsLastModifiedAndEtag() - { - // Arrange - var fileContents = new byte[0]; - var contentType = "text/plain"; - var expectedMediaType = contentType; - var lastModified = new DateTimeOffset(); - var entityTag = new EntityTagHeaderValue("\"Etag\""); - - // Act - var result = new FileContentResult(fileContents, contentType) - { - LastModified = lastModified, - EntityTag = entityTag - }; - - // Assert - Assert.Equal(lastModified, result.LastModified); - Assert.Equal(entityTag, result.EntityTag); - MediaTypeAssert.Equal(expectedMediaType, result.ContentType); - } - - [Fact] - public async Task WriteFileAsync_CopiesBuffer_ToOutputStream() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseFileContentResultTest.WriteFileAsync_CopiesBuffer_ToOutputStream(action); - } - - [Theory] - [InlineData(0, 4, "Hello", 5)] - [InlineData(6, 10, "World", 5)] - [InlineData(null, 5, "World", 5)] - [InlineData(6, null, "World", 5)] - public async Task WriteFileAsync_PreconditionStateShouldProcess_WritesRangeRequested(long? start, long? end, string expectedString, long contentLength) - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseFileContentResultTest - .WriteFileAsync_PreconditionStateShouldProcess_WritesRangeRequested(start, end, expectedString, contentLength, action); - } - - [Fact] - public async Task WriteFileAsync_IfRangeHeaderValid_WritesRangeRequest() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseFileContentResultTest.WriteFileAsync_IfRangeHeaderValid_WritesRangeRequest(action); - } - - [Fact] - public async Task WriteFileAsync_RangeProcessingNotEnabled_RangeRequestIgnored() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseFileContentResultTest.WriteFileAsync_RangeProcessingNotEnabled_RangeRequestIgnored(action); - } - - [Fact] - public async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestIgnored() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseFileContentResultTest.WriteFileAsync_IfRangeHeaderInvalid_RangeRequestIgnored(action); - } - - [Theory] - [InlineData("0-5")] - [InlineData("bytes = ")] - [InlineData("bytes = 1-4, 5-11")] - public async Task WriteFileAsync_PreconditionStateUnspecified_RangeRequestIgnored(string rangeString) - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseFileContentResultTest.WriteFileAsync_PreconditionStateUnspecified_RangeRequestIgnored(rangeString, action); - } - - [Theory] - [InlineData("bytes = 12-13")] - [InlineData("bytes = -0")] - public async Task WriteFileAsync_PreconditionStateUnspecified_RangeRequestedNotSatisfiable(string rangeString) - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseFileContentResultTest.WriteFileAsync_PreconditionStateUnspecified_RangeRequestedNotSatisfiable(rangeString, action); - } - - [Fact] - public async Task WriteFileAsync_PreconditionFailed_RangeRequestedIgnored() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseFileContentResultTest.WriteFileAsync_PreconditionFailed_RangeRequestedIgnored(action); - } - - [Fact] - public async Task WriteFileAsync_NotModified_RangeRequestedIgnored() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseFileContentResultTest.WriteFileAsync_NotModified_RangeRequestedIgnored(action); - } - - [Fact] - public async Task ExecuteResultAsync_SetsSuppliedContentTypeAndEncoding() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseFileContentResultTest.ExecuteResultAsync_SetsSuppliedContentTypeAndEncoding(action); - } - } -} diff --git a/src/Mvc/Mvc.Core/test/FileContentResult.cs b/src/Mvc/Mvc.Core/test/FileContentResult.cs deleted file mode 100644 index 7be596c7ee6a..000000000000 --- a/src/Mvc/Mvc.Core/test/FileContentResult.cs +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) .NET Foundation. 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.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Xunit; - -namespace Microsoft.AspNetCore.Mvc -{ - public class FileContentResultTest - { - [Fact] - public async Task WriteFileAsync_CopiesBuffer_ToOutputStream() - {var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - await BaseFileContentResultTest.WriteFileAsync_CopiesBuffer_ToOutputStream(action); - } - - [Theory] - [InlineData(0, 4, "Hello", 5)] - [InlineData(6, 10, "World", 5)] - [InlineData(null, 5, "World", 5)] - [InlineData(6, null, "World", 5)] - public async Task WriteFileAsync_PreconditionStateShouldProcess_WritesRangeRequested(long? start, long? end, string expectedString, long contentLength) - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BaseFileContentResultTest - .WriteFileAsync_PreconditionStateShouldProcess_WritesRangeRequested(start, end, expectedString, contentLength, action); - } - - [Fact] - public async Task WriteFileAsync_IfRangeHeaderValid_WritesRangeRequest() - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BaseFileContentResultTest.WriteFileAsync_IfRangeHeaderValid_WritesRangeRequest(action); - } - - [Fact] - public async Task WriteFileAsync_RangeProcessingNotEnabled_RangeRequestIgnored() - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BaseFileContentResultTest.WriteFileAsync_RangeProcessingNotEnabled_RangeRequestIgnored(action); - } - - [Fact] - public async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestIgnored() - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BaseFileContentResultTest.WriteFileAsync_IfRangeHeaderInvalid_RangeRequestIgnored(action); - } - - [Theory] - [InlineData("0-5")] - [InlineData("bytes = ")] - [InlineData("bytes = 1-4, 5-11")] - public async Task WriteFileAsync_PreconditionStateUnspecified_RangeRequestIgnored(string rangeString) - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BaseFileContentResultTest.WriteFileAsync_PreconditionStateUnspecified_RangeRequestIgnored(rangeString, action); - } - - [Theory] - [InlineData("bytes = 12-13")] - [InlineData("bytes = -0")] - public async Task WriteFileAsync_PreconditionStateUnspecified_RangeRequestedNotSatisfiable(string rangeString) - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BaseFileContentResultTest.WriteFileAsync_PreconditionStateUnspecified_RangeRequestedNotSatisfiable(rangeString, action); - } - - [Fact] - public async Task WriteFileAsync_PreconditionFailed_RangeRequestedIgnored() - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BaseFileContentResultTest.WriteFileAsync_PreconditionFailed_RangeRequestedIgnored(action); - } - - [Fact] - public async Task WriteFileAsync_NotModified_RangeRequestedIgnored() - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BaseFileContentResultTest.WriteFileAsync_NotModified_RangeRequestedIgnored(action); - } - - [Fact] - public async Task ExecuteResultAsync_SetsSuppliedContentTypeAndEncoding() - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BaseFileContentResultTest.ExecuteResultAsync_SetsSuppliedContentTypeAndEncoding(action); - } - } -} diff --git a/src/Mvc/Mvc.Core/test/FileContentResultTest.cs b/src/Mvc/Mvc.Core/test/FileContentResultTest.cs new file mode 100644 index 000000000000..5012dffa2518 --- /dev/null +++ b/src/Mvc/Mvc.Core/test/FileContentResultTest.cs @@ -0,0 +1,44 @@ +// Copyright (c) .NET Foundation. 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.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Internal; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Mvc +{ + public class FileContentResultTest : FileContentResultTestBase + { + protected override Task ExecuteAsync( + HttpContext httpContext, + byte[] buffer, + string contentType, + DateTimeOffset? lastModified = null, + EntityTagHeaderValue entityTag = null, + bool enableRangeProcessing = false) + { + var result = new FileContentResult(buffer, contentType) + { + EntityTag = entityTag, + LastModified = lastModified, + EnableRangeProcessing = enableRangeProcessing, + }; + + httpContext.RequestServices = new ServiceCollection() + .AddSingleton(NullLoggerFactory.Instance) + .AddSingleton, FileContentResultExecutor>() + .BuildServiceProvider(); + var context = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + return result.ExecuteResultAsync(context); + } + } +} diff --git a/src/Mvc/Mvc.Core/test/FileResultTest.cs b/src/Mvc/Mvc.Core/test/FileResultHelperTest.cs similarity index 97% rename from src/Mvc/Mvc.Core/test/FileResultTest.cs rename to src/Mvc/Mvc.Core/test/FileResultHelperTest.cs index 25e6d6f3634e..69b94ab7f7cb 100644 --- a/src/Mvc/Mvc.Core/test/FileResultTest.cs +++ b/src/Mvc/Mvc.Core/test/FileResultHelperTest.cs @@ -5,15 +5,14 @@ using System.IO; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Internal; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Net.Http.Headers; -using Moq; using Xunit; namespace Microsoft.AspNetCore.Mvc @@ -270,14 +269,14 @@ public void GetPreconditionState_ShouldProcess(string ifMatch, string ifNoneMatc actionContext.HttpContext = httpContext; // Act - var state = FileResultExecutorBase.GetPreconditionState( + var state = FileResultHelper.GetPreconditionState( httpRequestHeaders, lastModified, etag, NullLogger.Instance); // Assert - Assert.Equal(FileResultExecutorBase.PreconditionState.ShouldProcess, state); + Assert.Equal(FileResultHelper.PreconditionState.ShouldProcess, state); } [Theory] @@ -309,14 +308,14 @@ public void GetPreconditionState_ShouldNotProcess_PreconditionFailed(string ifMa actionContext.HttpContext = httpContext; // Act - var state = FileResultExecutorBase.GetPreconditionState( + var state = FileResultHelper.GetPreconditionState( httpRequestHeaders, lastModified, etag, NullLogger.Instance); // Assert - Assert.Equal(FileResultExecutorBase.PreconditionState.PreconditionFailed, state); + Assert.Equal(FileResultHelper.PreconditionState.PreconditionFailed, state); } [Theory] @@ -346,14 +345,14 @@ public void GetPreconditionState_ShouldNotProcess_NotModified(string ifMatch, st actionContext.HttpContext = httpContext; // Act - var state = FileResultExecutorBase.GetPreconditionState( + var state = FileResultHelper.GetPreconditionState( httpRequestHeaders, lastModified, etag, NullLogger.Instance); // Assert - Assert.Equal(FileResultExecutorBase.PreconditionState.NotModified, state); + Assert.Equal(FileResultHelper.PreconditionState.NotModified, state); } [Theory] @@ -374,7 +373,7 @@ public void IfRangeValid_IgnoreRangeRequest(string ifRangeString, bool expected) actionContext.HttpContext = httpContext; // Act - var ifRangeIsValid = FileResultExecutorBase.IfRangeValid( + var ifRangeIsValid = FileResultHelper.IfRangeValid( httpRequestHeaders, lastModified, etag, diff --git a/src/Mvc/Mvc.Core/test/FileStreamResultTest.cs b/src/Mvc/Mvc.Core/test/FileStreamResultTest.cs index fc40761013a3..e34e96c526fb 100644 --- a/src/Mvc/Mvc.Core/test/FileStreamResultTest.cs +++ b/src/Mvc/Mvc.Core/test/FileStreamResultTest.cs @@ -3,25 +3,44 @@ using System; using System.IO; -using System.Linq; -using System.Text; -using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Internal; using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Net.Http.Headers; -using Moq; using Xunit; namespace Microsoft.AspNetCore.Mvc { - public class FileStreamResultTest + public class FileStreamResultTest : FileStreamResultTestBase { + protected override Task ExecuteAsync( + HttpContext httpContext, + Stream stream, + string contentType, + DateTimeOffset? lastModified = null, + EntityTagHeaderValue entityTag = null, + bool enableRangeProcessing = false) + { + httpContext.RequestServices = new ServiceCollection() + .AddSingleton() + .AddSingleton, FileStreamResultExecutor>() + .BuildServiceProvider(); + + var actionContext = new ActionContext(httpContext, new(), new()); + var fileStreamResult = new FileStreamResult(stream, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + EnableRangeProcessing = enableRangeProcessing + }; + + return fileStreamResult.ExecuteResultAsync(actionContext); + } + [Fact] public void Constructor_SetsFileName() { @@ -48,7 +67,7 @@ public void Constructor_SetsContentTypeAndParameters() // Assert Assert.Equal(stream, result.FileStream); - MediaTypeAssert.Equal(expectedMediaType, result.ContentType); + Assert.Equal(expectedMediaType, result.ContentType); } [Fact] @@ -71,558 +90,8 @@ public void Constructor_SetsLastModifiedAndEtag() // Assert Assert.Equal(lastModified, result.LastModified); Assert.Equal(entityTag, result.EntityTag); - MediaTypeAssert.Equal(expectedMediaType, result.ContentType); - } - - [Theory] - [InlineData(0, 4, "Hello", 5)] - [InlineData(6, 10, "World", 5)] - [InlineData(null, 5, "World", 5)] - [InlineData(6, null, "World", 5)] - public async Task WriteFileAsync_PreconditionStateShouldProcess_WritesRangeRequested(long? start, long? end, string expectedString, long contentLength) - { - // Arrange - var contentType = "text/plain"; - var lastModified = new DateTimeOffset(); - var entityTag = new EntityTagHeaderValue("\"Etag\""); - var byteArray = Encoding.ASCII.GetBytes("Hello World"); - var readStream = new MemoryStream(byteArray); - readStream.SetLength(11); - - var result = new FileStreamResult(readStream, contentType) - { - LastModified = lastModified, - EntityTag = entityTag, - EnableRangeProcessing = true, - }; - - var httpContext = GetHttpContext(); - var requestHeaders = httpContext.Request.GetTypedHeaders(); - requestHeaders.Range = new RangeHeaderValue(start, end); - requestHeaders.IfMatch = new[] - { - new EntityTagHeaderValue("\"Etag\""), - }; - httpContext.Request.Method = HttpMethods.Get; - httpContext.Response.Body = new MemoryStream(); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); - - // Act - await result.ExecuteResultAsync(actionContext); - - // Assert - start = start ?? 11 - end; - end = start + contentLength - 1; - var httpResponse = actionContext.HttpContext.Response; - httpResponse.Body.Seek(0, SeekOrigin.Begin); - var streamReader = new StreamReader(httpResponse.Body); - var body = streamReader.ReadToEndAsync().Result; - var contentRange = new ContentRangeHeaderValue(start.Value, end.Value, byteArray.Length); - Assert.Equal(lastModified.ToString("R"), httpResponse.Headers.LastModified); - Assert.Equal(entityTag.ToString(), httpResponse.Headers.ETag); - Assert.Equal(StatusCodes.Status206PartialContent, httpResponse.StatusCode); - Assert.Equal("bytes", httpResponse.Headers.AcceptRanges); - Assert.Equal(contentRange.ToString(), httpResponse.Headers.ContentRange); - Assert.Equal(contentLength, httpResponse.ContentLength); - Assert.Equal(expectedString, body); - Assert.False(readStream.CanSeek); - } - - [Fact] - public async Task WriteFileAsync_IfRangeHeaderValid_WritesRequestedRange() - { - // Arrange - var contentType = "text/plain"; - var lastModified = DateTimeOffset.MinValue; - var entityTag = new EntityTagHeaderValue("\"Etag\""); - var byteArray = Encoding.ASCII.GetBytes("Hello World"); - var readStream = new MemoryStream(byteArray); - readStream.SetLength(11); - - var result = new FileStreamResult(readStream, contentType) - { - LastModified = lastModified, - EntityTag = entityTag, - EnableRangeProcessing = true, - }; - - var httpContext = GetHttpContext(); - var requestHeaders = httpContext.Request.GetTypedHeaders(); - requestHeaders.IfMatch = new[] - { - new EntityTagHeaderValue("\"Etag\""), - }; - requestHeaders.Range = new RangeHeaderValue(0, 4); - requestHeaders.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"Etag\"")); - httpContext.Request.Method = HttpMethods.Get; - httpContext.Response.Body = new MemoryStream(); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); - - // Act - await result.ExecuteResultAsync(actionContext); - - // Assert - var httpResponse = actionContext.HttpContext.Response; - httpResponse.Body.Seek(0, SeekOrigin.Begin); - var streamReader = new StreamReader(httpResponse.Body); - var body = streamReader.ReadToEndAsync().Result; - Assert.Equal(lastModified.ToString("R"), httpResponse.Headers.LastModified); - Assert.Equal(entityTag.ToString(), httpResponse.Headers.ETag); - var contentRange = new ContentRangeHeaderValue(0, 4, byteArray.Length); - Assert.Equal(StatusCodes.Status206PartialContent, httpResponse.StatusCode); - Assert.Equal("bytes", httpResponse.Headers.AcceptRanges); - Assert.Equal(contentRange.ToString(), httpResponse.Headers.ContentRange); - Assert.Equal(5, httpResponse.ContentLength); - Assert.Equal("Hello", body); - Assert.False(readStream.CanSeek); - } - - [Fact] - public async Task WriteFileAsync_RangeProcessingNotEnabled_RangeRequestedIgnored() - { - // Arrange - var contentType = "text/plain"; - var lastModified = DateTimeOffset.MinValue; - var entityTag = new EntityTagHeaderValue("\"Etag\""); - var byteArray = Encoding.ASCII.GetBytes("Hello World"); - var readStream = new MemoryStream(byteArray); - readStream.SetLength(11); - - var result = new FileStreamResult(readStream, contentType) - { - LastModified = lastModified, - EntityTag = entityTag, - }; - - var httpContext = GetHttpContext(); - var requestHeaders = httpContext.Request.GetTypedHeaders(); - requestHeaders.IfMatch = new[] - { - new EntityTagHeaderValue("\"Etag\""), - }; - requestHeaders.Range = new RangeHeaderValue(0, 4); - requestHeaders.IfRange = new RangeConditionHeaderValue(DateTimeOffset.MinValue); - httpContext.Request.Method = HttpMethods.Get; - httpContext.Response.Body = new MemoryStream(); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); - - // Act - await result.ExecuteResultAsync(actionContext); - - // Assert - var httpResponse = actionContext.HttpContext.Response; - httpResponse.Body.Seek(0, SeekOrigin.Begin); - var streamReader = new StreamReader(httpResponse.Body); - var body = streamReader.ReadToEndAsync().Result; - Assert.Equal(StatusCodes.Status200OK, httpResponse.StatusCode); - Assert.Equal(lastModified.ToString("R"), httpResponse.Headers.LastModified); - Assert.Equal(entityTag.ToString(), httpResponse.Headers.ETag); - Assert.Equal("Hello World", body); - Assert.False(readStream.CanSeek); - } - - [Fact] - public async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestedIgnored() - { - // Arrange - var contentType = "text/plain"; - var lastModified = DateTimeOffset.MinValue.AddDays(1); - var entityTag = new EntityTagHeaderValue("\"Etag\""); - var byteArray = Encoding.ASCII.GetBytes("Hello World"); - var readStream = new MemoryStream(byteArray); - readStream.SetLength(11); - - var result = new FileStreamResult(readStream, contentType) - { - LastModified = lastModified, - EntityTag = entityTag, - EnableRangeProcessing = true, - }; - - var httpContext = GetHttpContext(); - var requestHeaders = httpContext.Request.GetTypedHeaders(); - requestHeaders.IfMatch = new[] - { - new EntityTagHeaderValue("\"Etag\""), - }; - requestHeaders.Range = new RangeHeaderValue(0, 4); - requestHeaders.IfRange = new RangeConditionHeaderValue(DateTimeOffset.MinValue); - httpContext.Request.Method = HttpMethods.Get; - httpContext.Response.Body = new MemoryStream(); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); - - // Act - await result.ExecuteResultAsync(actionContext); - - // Assert - var httpResponse = actionContext.HttpContext.Response; - httpResponse.Body.Seek(0, SeekOrigin.Begin); - var streamReader = new StreamReader(httpResponse.Body); - var body = streamReader.ReadToEndAsync().Result; - Assert.Equal(StatusCodes.Status200OK, httpResponse.StatusCode); - Assert.Equal(lastModified.ToString("R"), httpResponse.Headers.LastModified); - Assert.Equal(entityTag.ToString(), httpResponse.Headers.ETag); - Assert.Equal("Hello World", body); - Assert.False(readStream.CanSeek); - } - - [Theory] - [InlineData("0-5")] - [InlineData("bytes = ")] - [InlineData("bytes = 1-4, 5-11")] - public async Task WriteFileAsync_PreconditionStateUnspecified_RangeRequestIgnored(string rangeString) - { - // Arrange - var contentType = "text/plain"; - var lastModified = new DateTimeOffset(); - var entityTag = new EntityTagHeaderValue("\"Etag\""); - var byteArray = Encoding.ASCII.GetBytes("Hello World"); - var readStream = new MemoryStream(byteArray); - - var result = new FileStreamResult(readStream, contentType) - { - LastModified = lastModified, - EntityTag = entityTag, - EnableRangeProcessing = true, - }; - - var httpContext = GetHttpContext(); - var requestHeaders = httpContext.Request.GetTypedHeaders(); - httpContext.Request.Headers.Range = rangeString; - httpContext.Request.Method = HttpMethods.Get; - httpContext.Response.Body = new MemoryStream(); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); - - // Act - await result.ExecuteResultAsync(actionContext); - - // Assert - var httpResponse = actionContext.HttpContext.Response; - httpResponse.Body.Seek(0, SeekOrigin.Begin); - var streamReader = new StreamReader(httpResponse.Body); - var body = streamReader.ReadToEndAsync().Result; - Assert.Empty(httpResponse.Headers.ContentRange); - Assert.Equal(StatusCodes.Status200OK, httpResponse.StatusCode); - Assert.Equal(lastModified.ToString("R"), httpResponse.Headers.LastModified); - Assert.Equal(entityTag.ToString(), httpResponse.Headers.ETag); - Assert.Equal("Hello World", body); - Assert.False(readStream.CanSeek); - } - - [Theory] - [InlineData("bytes = 12-13")] - [InlineData("bytes = -0")] - public async Task WriteFileAsync_PreconditionStateUnspecified_RangeRequestedNotSatisfiable(string rangeString) - { - // Arrange - var contentType = "text/plain"; - var lastModified = new DateTimeOffset(); - var entityTag = new EntityTagHeaderValue("\"Etag\""); - var byteArray = Encoding.ASCII.GetBytes("Hello World"); - var readStream = new MemoryStream(byteArray); - - var result = new FileStreamResult(readStream, contentType) - { - LastModified = lastModified, - EntityTag = entityTag, - EnableRangeProcessing = true, - }; - - var httpContext = GetHttpContext(); - httpContext.Request.Headers.Range = rangeString; - httpContext.Request.Method = HttpMethods.Get; - httpContext.Response.Body = new MemoryStream(); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); - - // Act - await result.ExecuteResultAsync(actionContext); - - // Assert - var httpResponse = actionContext.HttpContext.Response; - httpResponse.Body.Seek(0, SeekOrigin.Begin); - var streamReader = new StreamReader(httpResponse.Body); - var body = streamReader.ReadToEndAsync().Result; - var contentRange = new ContentRangeHeaderValue(byteArray.Length); - Assert.Equal(lastModified.ToString("R"), httpResponse.Headers.LastModified); - Assert.Equal(entityTag.ToString(), httpResponse.Headers.ETag); - Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, httpResponse.StatusCode); - Assert.Equal("bytes", httpResponse.Headers.AcceptRanges); - Assert.Equal(contentRange.ToString(), httpResponse.Headers.ContentRange); - Assert.Equal(0, httpResponse.ContentLength); - Assert.Empty(body); - Assert.False(readStream.CanSeek); + Assert.Equal(expectedMediaType, result.ContentType); } - [Fact] - public async Task WriteFileAsync_RangeRequested_PreconditionFailed() - { - // Arrange - var contentType = "text/plain"; - var lastModified = new DateTimeOffset(); - var entityTag = new EntityTagHeaderValue("\"Etag\""); - var byteArray = Encoding.ASCII.GetBytes("Hello World"); - var readStream = new MemoryStream(byteArray); - - var result = new FileStreamResult(readStream, contentType) - { - LastModified = lastModified, - EntityTag = entityTag, - EnableRangeProcessing = true, - }; - - var httpContext = GetHttpContext(); - var requestHeaders = httpContext.Request.GetTypedHeaders(); - requestHeaders.IfMatch = new[] - { - new EntityTagHeaderValue("\"NotEtag\""), - }; - httpContext.Request.Headers.Range = "bytes = 0-6"; - httpContext.Request.Method = HttpMethods.Get; - httpContext.Response.Body = new MemoryStream(); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); - - // Act - await result.ExecuteResultAsync(actionContext); - - // Assert - var httpResponse = actionContext.HttpContext.Response; - httpResponse.Body.Seek(0, SeekOrigin.Begin); - var streamReader = new StreamReader(httpResponse.Body); - var body = streamReader.ReadToEndAsync().Result; - Assert.Equal(StatusCodes.Status412PreconditionFailed, httpResponse.StatusCode); - Assert.Null(httpResponse.ContentLength); - Assert.Empty(httpResponse.Headers.ContentRange); - Assert.NotEmpty(httpResponse.Headers.LastModified); - Assert.Empty(body); - Assert.False(readStream.CanSeek); - } - - [Fact] - public async Task WriteFileAsync_NotModified_RangeRequestedIgnored() - { - // Arrange - var contentType = "text/plain"; - var lastModified = new DateTimeOffset(); - var entityTag = new EntityTagHeaderValue("\"Etag\""); - var byteArray = Encoding.ASCII.GetBytes("Hello World"); - var readStream = new MemoryStream(byteArray); - - var result = new FileStreamResult(readStream, contentType) - { - LastModified = lastModified, - EntityTag = entityTag, - EnableRangeProcessing = true, - }; - - var httpContext = GetHttpContext(); - var requestHeaders = httpContext.Request.GetTypedHeaders(); - requestHeaders.IfNoneMatch = new[] - { - new EntityTagHeaderValue("\"Etag\""), - }; - httpContext.Request.Headers.Range = "bytes = 0-6"; - httpContext.Request.Method = HttpMethods.Get; - httpContext.Response.Body = new MemoryStream(); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); - - // Act - await result.ExecuteResultAsync(actionContext); - - // Assert - var httpResponse = actionContext.HttpContext.Response; - httpResponse.Body.Seek(0, SeekOrigin.Begin); - var streamReader = new StreamReader(httpResponse.Body); - var body = await streamReader.ReadToEndAsync(); - Assert.Equal(StatusCodes.Status304NotModified, httpResponse.StatusCode); - Assert.Null(httpResponse.ContentLength); - Assert.Empty(httpResponse.Headers.ContentRange); - Assert.False(httpResponse.Headers.ContainsKey(HeaderNames.ContentType)); - Assert.NotEmpty(httpResponse.Headers.LastModified); - Assert.Empty(body); - Assert.False(readStream.CanSeek); - } - - [Theory] - [InlineData(0)] - [InlineData(null)] - public async Task WriteFileAsync_RangeRequested_FileLengthZeroOrNull(long? fileLength) - { - // Arrange - var contentType = "text/plain"; - var lastModified = new DateTimeOffset(); - var entityTag = new EntityTagHeaderValue("\"Etag\""); - var byteArray = Encoding.ASCII.GetBytes(""); - var readStream = new MemoryStream(byteArray); - fileLength = fileLength ?? 0L; - readStream.SetLength(fileLength.Value); - var result = new FileStreamResult(readStream, contentType) - { - LastModified = lastModified, - EntityTag = entityTag, - EnableRangeProcessing = true, - }; - - var httpContext = GetHttpContext(); - var requestHeaders = httpContext.Request.GetTypedHeaders(); - requestHeaders.Range = new RangeHeaderValue(0, 5); - requestHeaders.IfMatch = new[] - { - new EntityTagHeaderValue("\"Etag\""), - }; - httpContext.Request.Method = HttpMethods.Get; - httpContext.Response.Body = new MemoryStream(); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); - - // Act - await result.ExecuteResultAsync(actionContext); - - // Assert - var httpResponse = actionContext.HttpContext.Response; - httpResponse.Body.Seek(0, SeekOrigin.Begin); - var streamReader = new StreamReader(httpResponse.Body); - var body = streamReader.ReadToEndAsync().Result; - Assert.Equal(lastModified.ToString("R"), httpResponse.Headers.LastModified); - Assert.Equal(entityTag.ToString(), httpResponse.Headers.ETag); - var contentRange = new ContentRangeHeaderValue(byteArray.Length); - Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, httpResponse.StatusCode); - Assert.Equal("bytes", httpResponse.Headers.AcceptRanges); - Assert.Equal(contentRange.ToString(), httpResponse.Headers.ContentRange); - Assert.Equal(0, httpResponse.ContentLength); - Assert.Empty(body); - Assert.False(readStream.CanSeek); - } - - [Fact] - public async Task WriteFileAsync_WritesResponse_InChunksOfFourKilobytes() - { - // Arrange - var mockReadStream = new Mock(); - mockReadStream.SetupSequence(s => s.ReadAsync(It.IsAny(), 0, 0x1000, CancellationToken.None)) - .Returns(Task.FromResult(0x1000)) - .Returns(Task.FromResult(0x500)) - .Returns(Task.FromResult(0)); - - var mockBodyStream = new Mock(); - mockBodyStream - .Setup(s => s.WriteAsync(It.IsAny(), 0, 0x1000, CancellationToken.None)) - .Returns(Task.FromResult(0)); - - mockBodyStream - .Setup(s => s.WriteAsync(It.IsAny(), 0, 0x500, CancellationToken.None)) - .Returns(Task.FromResult(0)); - - var result = new FileStreamResult(mockReadStream.Object, "text/plain"); - - var httpContext = GetHttpContext(); - httpContext.Response.Body = mockBodyStream.Object; - - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); - - // Act - await result.ExecuteResultAsync(actionContext); - - // Assert - mockReadStream.Verify(); - mockBodyStream.Verify(); - } - - [Fact] - public async Task WriteFileAsync_CopiesProvidedStream_ToOutputStream() - { - // Arrange - // Generate an array of bytes with a predictable pattern - // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, F, 10, 11, 12, 13 - var originalBytes = Enumerable.Range(0, 0x1234) - .Select(b => (byte)(b % 20)).ToArray(); - - var originalStream = new MemoryStream(originalBytes); - - var httpContext = GetHttpContext(); - var outStream = new MemoryStream(); - httpContext.Response.Body = outStream; - - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); - - var result = new FileStreamResult(originalStream, "text/plain"); - - // Act - await result.ExecuteResultAsync(actionContext); - - // Assert - var outBytes = outStream.ToArray(); - Assert.True(originalBytes.SequenceEqual(outBytes)); - Assert.False(originalStream.CanSeek); - } - - [Fact] - public async Task SetsSuppliedContentTypeAndEncoding() - { - // Arrange - var expectedContentType = "text/foo; charset=us-ascii"; - // Generate an array of bytes with a predictable pattern - // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, F, 10, 11, 12, 13 - var originalBytes = Enumerable.Range(0, 0x1234) - .Select(b => (byte)(b % 20)).ToArray(); - - var originalStream = new MemoryStream(originalBytes); - - var httpContext = GetHttpContext(); - var outStream = new MemoryStream(); - httpContext.Response.Body = outStream; - - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); - - var result = new FileStreamResult(originalStream, expectedContentType); - - // Act - await result.ExecuteResultAsync(actionContext); - - // Assert - var outBytes = outStream.ToArray(); - Assert.True(originalBytes.SequenceEqual(outBytes)); - Assert.Equal(expectedContentType, httpContext.Response.ContentType); - Assert.False(originalStream.CanSeek); - } - - [Fact] - public async Task HeadRequest_DoesNotWriteToBody_AndClosesReadStream() - { - // Arrange - var readStream = new MemoryStream(Encoding.UTF8.GetBytes("Hello, World!")); - - var httpContext = GetHttpContext(); - httpContext.Request.Method = "HEAD"; - var outStream = new MemoryStream(); - httpContext.Response.Body = outStream; - - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); - - var result = new FileStreamResult(readStream, "text/plain"); - - // Act - await result.ExecuteResultAsync(actionContext); - - // Assert - Assert.False(readStream.CanSeek); - Assert.Equal(200, httpContext.Response.StatusCode); - Assert.Equal(0, httpContext.Response.Body.Length); - } - - private static IServiceCollection CreateServices() - { - var services = new ServiceCollection(); - services.AddSingleton, FileStreamResultExecutor>(); - services.AddSingleton(NullLoggerFactory.Instance); - return services; - } - - private static HttpContext GetHttpContext() - { - var services = CreateServices(); - - var httpContext = new DefaultHttpContext(); - httpContext.RequestServices = services.BuildServiceProvider(); - return httpContext; - } } -} \ No newline at end of file +} diff --git a/src/Mvc/Mvc.Core/test/Formatters/ResponseContentTypeHelperTest.cs b/src/Mvc/Mvc.Core/test/Formatters/ResponseContentTypeHelperTest.cs index 6895f5528822..362278f86409 100644 --- a/src/Mvc/Mvc.Core/test/Formatters/ResponseContentTypeHelperTest.cs +++ b/src/Mvc/Mvc.Core/test/Formatters/ResponseContentTypeHelperTest.cs @@ -2,10 +2,11 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Text; +using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Net.Http.Headers; using Xunit; -namespace Microsoft.AspNetCore.Mvc.Formatters +namespace Microsoft.AspNetCore.Internal { public class ResponseContentTypeHelperTest { @@ -108,7 +109,8 @@ public void GetsExpectedContentTypeAndEncoding( ResponseContentTypeHelper.ResolveContentTypeAndEncoding( contentType?.ToString(), responseContentType, - defaultContentType, + (defaultContentType, Encoding.UTF8), + MediaType.GetEncoding, out var resolvedContentType, out var resolvedContentTypeEncoding); @@ -127,7 +129,8 @@ public void DoesNotThrowException_OnInvalidResponseContentType() ResponseContentTypeHelper.ResolveContentTypeAndEncoding( null, expectedContentType, - defaultContentType, + (defaultContentType, Encoding.UTF8), + MediaType.GetEncoding, out var resolvedContentType, out var resolvedContentTypeEncoding); diff --git a/src/Mvc/Mvc.Core/test/Microsoft.AspNetCore.Mvc.Core.Test.csproj b/src/Mvc/Mvc.Core/test/Microsoft.AspNetCore.Mvc.Core.Test.csproj index bad827b0182f..e8aa8f9575e3 100644 --- a/src/Mvc/Mvc.Core/test/Microsoft.AspNetCore.Mvc.Core.Test.csproj +++ b/src/Mvc/Mvc.Core/test/Microsoft.AspNetCore.Mvc.Core.Test.csproj @@ -15,5 +15,6 @@ + diff --git a/src/Mvc/Mvc.Core/test/PhysicalFileActionResultTest.cs b/src/Mvc/Mvc.Core/test/PhysicalFileActionResultTest.cs deleted file mode 100644 index 7677af32f434..000000000000 --- a/src/Mvc/Mvc.Core/test/PhysicalFileActionResultTest.cs +++ /dev/null @@ -1,204 +0,0 @@ -// Copyright (c) .NET Foundation. 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 System.Threading.Tasks; -using Xunit; - -namespace Microsoft.AspNetCore.Mvc -{ - public class PhysicalFileActionResultTest - { - [Fact] - public void Constructor_SetsFileName() - { - // Arrange - var path = Path.GetFullPath("helllo.txt"); - - // Act - var result = new PhysicalFileResult(path, "text/plain"); - - // Assert - Assert.Equal(path, result.FileName); - } - - [Fact] - public void Constructor_SetsContentTypeAndParameters() - { - // Arrange - var path = Path.GetFullPath("helllo.txt"); - var contentType = "text/plain; charset=us-ascii; p1=p1-value"; - var expectedMediaType = contentType; - - // Act - var result = new PhysicalFileResult(path, contentType); - - // Assert - Assert.Equal(path, result.FileName); - MediaTypeAssert.Equal(expectedMediaType, result.ContentType); - } - - [Theory] - [InlineData(0, 3, "File", 4)] - [InlineData(8, 13, "Result", 6)] - [InlineData(null, 5, "ts�", 5)] - [InlineData(8, null, "ResultTestFile contents�", 26)] - public async Task WriteFileAsync_WritesRangeRequested(long? start, long? end, string expectedString, long contentLength) - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BasePhysicalFileResultTest.WriteFileAsync_WritesRangeRequested( - start, - end, - expectedString, - contentLength, - action); - } - - [Fact] - public async Task WriteFileAsync_IfRangeHeaderValid_WritesRequestedRange() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BasePhysicalFileResultTest.WriteFileAsync_IfRangeHeaderValid_WritesRequestedRange(action); - } - - [Fact] - public async Task WriteFileAsync_RangeProcessingNotEnabled_RangeRequestedIgnored() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BasePhysicalFileResultTest.WriteFileAsync_RangeProcessingNotEnabled_RangeRequestedIgnored(action); - } - - [Fact] - public async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestedIgnored() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BasePhysicalFileResultTest.WriteFileAsync_IfRangeHeaderInvalid_RangeRequestedIgnored(action); - } - - [Theory] - [InlineData("0-5")] - [InlineData("bytes = ")] - [InlineData("bytes = 1-4, 5-11")] - public async Task WriteFileAsync_RangeHeaderMalformed_RangeRequestIgnored(string rangeString) - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BasePhysicalFileResultTest.WriteFileAsync_RangeHeaderMalformed_RangeRequestIgnored(rangeString, action); - } - - [Theory] - [InlineData("bytes = 35-36")] - [InlineData("bytes = -0")] - public async Task WriteFileAsync_RangeRequestedNotSatisfiable(string rangeString) - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BasePhysicalFileResultTest.WriteFileAsync_RangeRequestedNotSatisfiable(rangeString, action); - } - - [Fact] - public async Task WriteFileAsync_RangeRequested_PreconditionFailed() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BasePhysicalFileResultTest.WriteFileAsync_RangeRequested_PreconditionFailed(action); - } - - [Fact] - public async Task WriteFileAsync_RangeRequested_NotModified() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BasePhysicalFileResultTest.WriteFileAsync_RangeRequested_NotModified(action); - } - - [Fact] - public async Task ExecuteResultAsync_CallsSendFileAsync_IfIHttpSendFilePresent() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BasePhysicalFileResultTest.ExecuteResultAsync_CallsSendFileAsync_IfIHttpSendFilePresent(action); - } - - [Theory] - [InlineData(0, 3, 4)] - [InlineData(8, 13, 6)] - [InlineData(null, 3, 3)] - [InlineData(8, null, 26)] - public async Task ExecuteResultAsync_CallsSendFileAsyncWithRequestedRange_IfIHttpSendFilePresent(long? start, long? end, long contentLength) - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BasePhysicalFileResultTest.ExecuteResultAsync_CallsSendFileAsyncWithRequestedRange_IfIHttpSendFilePresent(start, end, contentLength, action); - } - - [Fact] - public async Task ExecuteResultAsync_SetsSuppliedContentTypeAndEncoding() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BasePhysicalFileResultTest.ExecuteResultAsync_SetsSuppliedContentTypeAndEncoding(action); - } - - [Fact] - public async Task ExecuteResultAsync_WorksWithAbsolutePaths() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BasePhysicalFileResultTest.ExecuteResultAsync_WorksWithAbsolutePaths(action); - } - - [Theory] - [InlineData("FilePathResultTestFile.txt")] - [InlineData("./FilePathResultTestFile.txt")] - [InlineData(".\\FilePathResultTestFile.txt")] - [InlineData("~/FilePathResultTestFile.txt")] - [InlineData("..\\TestFiles/FilePathResultTestFile.txt")] - [InlineData("..\\TestFiles\\FilePathResultTestFile.txt")] - [InlineData("..\\TestFiles/SubFolder/SubFolderTestFile.txt")] - [InlineData("..\\TestFiles\\SubFolder\\SubFolderTestFile.txt")] - [InlineData("..\\TestFiles/SubFolder\\SubFolderTestFile.txt")] - [InlineData("..\\TestFiles\\SubFolder/SubFolderTestFile.txt")] - [InlineData("~/SubFolder/SubFolderTestFile.txt")] - [InlineData("~/SubFolder\\SubFolderTestFile.txt")] - public async Task ExecuteAsync_ThrowsNotSupported_ForNonRootedPaths(string path) - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BasePhysicalFileResultTest.ExecuteAsync_ThrowsNotSupported_ForNonRootedPaths(path, action); - } - - [Theory] - [InlineData("/SubFolder/SubFolderTestFile.txt")] - [InlineData("\\SubFolder\\SubFolderTestFile.txt")] - [InlineData("/SubFolder\\SubFolderTestFile.txt")] - [InlineData("\\SubFolder/SubFolderTestFile.txt")] - [InlineData("./SubFolder/SubFolderTestFile.txt")] - [InlineData(".\\SubFolder\\SubFolderTestFile.txt")] - [InlineData("./SubFolder\\SubFolderTestFile.txt")] - [InlineData(".\\SubFolder/SubFolderTestFile.txt")] - public void ExecuteAsync_ThrowsDirectoryNotFound_IfItCanNotFindTheDirectory_ForRootPaths(string path) - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - BasePhysicalFileResultTest - .ExecuteAsync_ThrowsDirectoryNotFound_IfItCanNotFindTheDirectory_ForRootPaths(path, action); - } - - [Theory] - [InlineData("/FilePathResultTestFile.txt")] - [InlineData("\\FilePathResultTestFile.txt")] - public void ExecuteAsync_ThrowsFileNotFound_WhenFileDoesNotExist_ForRootPaths(string path) - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - BasePhysicalFileResultTest - .ExecuteAsync_ThrowsFileNotFound_WhenFileDoesNotExist_ForRootPaths(path, action); - } - } -} diff --git a/src/Mvc/Mvc.Core/test/PhysicalFileResult.cs b/src/Mvc/Mvc.Core/test/PhysicalFileResult.cs deleted file mode 100644 index 5cadbb3db739..000000000000 --- a/src/Mvc/Mvc.Core/test/PhysicalFileResult.cs +++ /dev/null @@ -1,173 +0,0 @@ -// Copyright (c) .NET Foundation. 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.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Xunit; - -namespace Microsoft.AspNetCore.Mvc -{ - public class PhysicalFileResultTest - { - [Theory] - [InlineData(0, 3, "File", 4)] - [InlineData(8, 13, "Result", 6)] - [InlineData(null, 5, "ts�", 5)] - [InlineData(8, null, "ResultTestFile contents�", 26)] - public async Task WriteFileAsync_WritesRangeRequested(long? start, long? end, string expectedString, long contentLength) - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BasePhysicalFileResultTest.WriteFileAsync_WritesRangeRequested( - start, - end, - expectedString, - contentLength, - action); - } - - [Fact] - public async Task WriteFileAsync_IfRangeHeaderValid_WritesRequestedRange() - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BasePhysicalFileResultTest.WriteFileAsync_IfRangeHeaderValid_WritesRequestedRange(action); - } - - [Fact] - public async Task WriteFileAsync_RangeProcessingNotEnabled_RangeRequestedIgnored() - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BasePhysicalFileResultTest.WriteFileAsync_RangeProcessingNotEnabled_RangeRequestedIgnored(action); - } - - [Fact] - public async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestedIgnored() - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BasePhysicalFileResultTest.WriteFileAsync_IfRangeHeaderInvalid_RangeRequestedIgnored(action); - } - - [Theory] - [InlineData("0-5")] - [InlineData("bytes = ")] - [InlineData("bytes = 1-4, 5-11")] - public async Task WriteFileAsync_RangeHeaderMalformed_RangeRequestIgnored(string rangeString) - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BasePhysicalFileResultTest.WriteFileAsync_RangeHeaderMalformed_RangeRequestIgnored(rangeString, action); - } - - [Theory] - [InlineData("bytes = 35-36")] - [InlineData("bytes = -0")] - public async Task WriteFileAsync_RangeRequestedNotSatisfiable(string rangeString) - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BasePhysicalFileResultTest.WriteFileAsync_RangeRequestedNotSatisfiable(rangeString, action); - } - - [Fact] - public async Task WriteFileAsync_RangeRequested_PreconditionFailed() - {var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - await BasePhysicalFileResultTest.WriteFileAsync_RangeRequested_PreconditionFailed(action); - } - - [Fact] - public async Task WriteFileAsync_RangeRequested_NotModified() - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BasePhysicalFileResultTest.WriteFileAsync_RangeRequested_NotModified(action); - } - - [Fact] - public async Task ExecuteResultAsync_CallsSendFileAsync_IfIHttpSendFilePresent() - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BasePhysicalFileResultTest.ExecuteResultAsync_CallsSendFileAsync_IfIHttpSendFilePresent(action); - } - - [Theory] - [InlineData(0, 3, 4)] - [InlineData(8, 13, 6)] - [InlineData(null, 3, 3)] - [InlineData(8, null, 26)] - public async Task ExecuteResultAsync_CallsSendFileAsyncWithRequestedRange_IfIHttpSendFilePresent(long? start, long? end, long contentLength) - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BasePhysicalFileResultTest.ExecuteResultAsync_CallsSendFileAsyncWithRequestedRange_IfIHttpSendFilePresent(start, end, contentLength, action); - } - - [Fact] - public async Task ExecuteResultAsync_SetsSuppliedContentTypeAndEncoding() - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BasePhysicalFileResultTest.ExecuteResultAsync_SetsSuppliedContentTypeAndEncoding(action); - } - - [Fact] - public async Task ExecuteResultAsync_WorksWithAbsolutePaths() - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BasePhysicalFileResultTest.ExecuteResultAsync_WorksWithAbsolutePaths(action); - } - - [Theory] - [InlineData("FilePathResultTestFile.txt")] - [InlineData("./FilePathResultTestFile.txt")] - [InlineData(".\\FilePathResultTestFile.txt")] - [InlineData("~/FilePathResultTestFile.txt")] - [InlineData("..\\TestFiles/FilePathResultTestFile.txt")] - [InlineData("..\\TestFiles\\FilePathResultTestFile.txt")] - [InlineData("..\\TestFiles/SubFolder/SubFolderTestFile.txt")] - [InlineData("..\\TestFiles\\SubFolder\\SubFolderTestFile.txt")] - [InlineData("..\\TestFiles/SubFolder\\SubFolderTestFile.txt")] - [InlineData("..\\TestFiles\\SubFolder/SubFolderTestFile.txt")] - [InlineData("~/SubFolder/SubFolderTestFile.txt")] - [InlineData("~/SubFolder\\SubFolderTestFile.txt")] - public async Task ExecuteAsync_ThrowsNotSupported_ForNonRootedPaths(string path) - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BasePhysicalFileResultTest.ExecuteAsync_ThrowsNotSupported_ForNonRootedPaths(path, action); - } - - [Theory] - [InlineData("/SubFolder/SubFolderTestFile.txt")] - [InlineData("\\SubFolder\\SubFolderTestFile.txt")] - [InlineData("/SubFolder\\SubFolderTestFile.txt")] - [InlineData("\\SubFolder/SubFolderTestFile.txt")] - [InlineData("./SubFolder/SubFolderTestFile.txt")] - [InlineData(".\\SubFolder\\SubFolderTestFile.txt")] - [InlineData("./SubFolder\\SubFolderTestFile.txt")] - [InlineData(".\\SubFolder/SubFolderTestFile.txt")] - public void ExecuteAsync_ThrowsDirectoryNotFound_IfItCanNotFindTheDirectory_ForRootPaths(string path) - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - BasePhysicalFileResultTest - .ExecuteAsync_ThrowsDirectoryNotFound_IfItCanNotFindTheDirectory_ForRootPaths(path, action); - } - - [Theory] - [InlineData("/FilePathResultTestFile.txt")] - [InlineData("\\FilePathResultTestFile.txt")] - public void ExecuteAsync_ThrowsFileNotFound_WhenFileDoesNotExist_ForRootPaths(string path) - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - BasePhysicalFileResultTest - .ExecuteAsync_ThrowsFileNotFound_WhenFileDoesNotExist_ForRootPaths(path, action); - } - } -} diff --git a/src/Mvc/Mvc.Core/test/PhysicalFileResultTest.cs b/src/Mvc/Mvc.Core/test/PhysicalFileResultTest.cs new file mode 100644 index 000000000000..906ecdd52a7a --- /dev/null +++ b/src/Mvc/Mvc.Core/test/PhysicalFileResultTest.cs @@ -0,0 +1,66 @@ +// Copyright (c) .NET Foundation. 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.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Internal; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Mvc +{ + public class PhysicalFileResultTest : PhysicalFileResultTestBase + { + protected override Task ExecuteAsync( + HttpContext httpContext, + string path, + string contentType, + DateTimeOffset? lastModified = null, + EntityTagHeaderValue entityTag = null, + bool enableRangeProcessing = false) + { + var fileResult = new PhysicalFileResult(path, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + EnableRangeProcessing = enableRangeProcessing, + }; + + httpContext.RequestServices = CreateServices(); + var actionContext = new ActionContext(httpContext, new(), new()); + + return fileResult.ExecuteResultAsync(actionContext); + } + + private class TestPhysicalFileResultExecutor : PhysicalFileResultExecutor + { + public TestPhysicalFileResultExecutor(ILoggerFactory loggerFactory) + : base(loggerFactory) + { + } + + protected override FileMetadata GetFileInfo(string path) + { + var lastModified = DateTimeOffset.MinValue.AddDays(1); + return new FileMetadata + { + Exists = true, + Length = 34, + LastModified = new DateTimeOffset(lastModified.Year, lastModified.Month, lastModified.Day, lastModified.Hour, lastModified.Minute, lastModified.Second, TimeSpan.FromSeconds(0)) + }; + } + } + + private static IServiceProvider CreateServices() + { + var services = new ServiceCollection(); + services.AddSingleton, TestPhysicalFileResultExecutor>(); + services.AddSingleton(NullLoggerFactory.Instance); + return services.BuildServiceProvider(); + } + } +} diff --git a/src/Mvc/Mvc.Core/test/RedirectResultTest.cs b/src/Mvc/Mvc.Core/test/RedirectResultTest.cs index 68e310a82aac..8a170e892cba 100644 --- a/src/Mvc/Mvc.Core/test/RedirectResultTest.cs +++ b/src/Mvc/Mvc.Core/test/RedirectResultTest.cs @@ -4,49 +4,79 @@ using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Internal; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Microsoft.AspNetCore.Mvc { - public class RedirectResultTest + public class RedirectResultTest : RedirectResultTestBase { - [Theory] - [InlineData("", "/Home/About", "/Home/About")] - [InlineData("/myapproot", "/test", "/test")] - public async Task Execute_ReturnsContentPath_WhenItDoesNotStartWithTilde( - string appRoot, - string contentPath, - string expectedPath) + protected override Task ExecuteAsync(HttpContext httpContext, string contentPath) { - var action - = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BaseRedirectResultTest.Execute_ReturnsContentPath_WhenItDoesNotStartWithTilde( - appRoot, - contentPath, - expectedPath, - action); + httpContext.RequestServices = GetServiceProvider(); + var actionContext = new ActionContext(httpContext, new(), new()); + + var redirectResult = new RedirectResult(contentPath); + return redirectResult.ExecuteResultAsync(actionContext); + } + + private static IServiceProvider GetServiceProvider() + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton, RedirectResultExecutor>(); + serviceCollection.AddSingleton(); + serviceCollection.AddTransient(); + return serviceCollection.BuildServiceProvider(); + } + + [Fact] + public void RedirectResult_Constructor_WithParameterUrl_SetsResultUrlAndNotPermanentOrPreserveMethod() + { + // Arrange + var url = "/test/url"; + + // Act + var result = new RedirectResult(url); + + // Assert + Assert.False(result.PreserveMethod); + Assert.False(result.Permanent); + Assert.Same(url, result.Url); + } + + [Fact] + public void RedirectResult_Constructor_WithParameterUrlAndPermanent_SetsResultUrlAndPermanentNotPreserveMethod() + { + // Arrange + var url = "/test/url"; + + // Act + var result = new RedirectResult(url, permanent: true); + + // Assert + Assert.False(result.PreserveMethod); + Assert.True(result.Permanent); + Assert.Same(url, result.Url); } - [Theory] - [InlineData(null, "~/Home/About", "/Home/About")] - [InlineData("/", "~/Home/About", "/Home/About")] - [InlineData("/", "~/", "/")] - [InlineData("", "~/Home/About", "/Home/About")] - [InlineData("/myapproot", "~/", "/myapproot/")] - public async Task Execute_ReturnsAppRelativePath_WhenItStartsWithTilde( - string appRoot, - string contentPath, - string expectedPath) + [Fact] + public void RedirectResult_Constructor_WithParameterUrlPermanentAndPreservesMethod_SetsResultUrlPermanentAndPreservesMethod() { - var action - = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BaseRedirectResultTest.Execute_ReturnsAppRelativePath_WhenItStartsWithTilde( - appRoot, - contentPath, - expectedPath, - action); + // Arrange + var url = "/test/url"; + + // Act + var result = new RedirectResult(url, permanent: true, preserveMethod: true); + + // Assert + Assert.True(result.PreserveMethod); + Assert.True(result.Permanent); + Assert.Same(url, result.Url); } } } diff --git a/src/Mvc/Mvc.Core/test/Routing/UrlHelperExtensionsTest.cs b/src/Mvc/Mvc.Core/test/Routing/UrlHelperExtensionsTest.cs index 02ebfc549b52..0ea5e72b1027 100644 --- a/src/Mvc/Mvc.Core/test/Routing/UrlHelperExtensionsTest.cs +++ b/src/Mvc/Mvc.Core/test/Routing/UrlHelperExtensionsTest.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; @@ -6,13 +6,12 @@ using System.Linq; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Testing; using Moq; using Xunit; -namespace Microsoft.AspNetCore.Mvc.Core.Test.Routing +namespace Microsoft.AspNetCore.Mvc.Routing { public class UrlHelperExtensionsTest { diff --git a/src/Mvc/Mvc.Core/test/SignInResultTest.cs b/src/Mvc/Mvc.Core/test/SignInResultTest.cs index b4a75abd9005..15f895bd5b5a 100644 --- a/src/Mvc/Mvc.Core/test/SignInResultTest.cs +++ b/src/Mvc/Mvc.Core/test/SignInResultTest.cs @@ -100,70 +100,6 @@ public async Task ExecuteResultAsync_InvokesSignInAsyncOnConfiguredScheme() auth.Verify(); } - [Fact] - public async Task ExecuteAsync_InvokesSignInAsyncOnAuthenticationManager() - { - // Arrange - var principal = new ClaimsPrincipal(); - var httpContext = new Mock(); - var auth = new Mock(); - auth - .Setup(c => c.SignInAsync(httpContext.Object, "", principal, null)) - .Returns(Task.CompletedTask) - .Verifiable(); - httpContext.Setup(c => c.RequestServices).Returns(CreateServices(auth.Object)); - var result = new SignInResult("", principal, null); - - // Act - await ((IResult)result).ExecuteAsync(httpContext.Object); - - // Assert - auth.Verify(); - } - - [Fact] - public async Task ExecuteAsync_InvokesSignInAsyncOnAuthenticationManagerWithDefaultScheme() - { - // Arrange - var principal = new ClaimsPrincipal(); - var httpContext = new Mock(); - var auth = new Mock(); - auth - .Setup(c => c.SignInAsync(httpContext.Object, null, principal, null)) - .Returns(Task.CompletedTask) - .Verifiable(); - httpContext.Setup(c => c.RequestServices).Returns(CreateServices(auth.Object)); - var result = new SignInResult(principal); - - // Act - await ((IResult)result).ExecuteAsync(httpContext.Object); - - // Assert - auth.Verify(); - } - - [Fact] - public async Task ExecuteAsync_InvokesSignInAsyncOnConfiguredScheme() - { - // Arrange - var principal = new ClaimsPrincipal(); - var authProperties = new AuthenticationProperties(); - var httpContext = new Mock(); - var auth = new Mock(); - auth - .Setup(c => c.SignInAsync(httpContext.Object, "Scheme1", principal, authProperties)) - .Returns(Task.CompletedTask) - .Verifiable(); - httpContext.Setup(c => c.RequestServices).Returns(CreateServices(auth.Object)); - var result = new SignInResult("Scheme1", principal, authProperties); - - // Act - await ((IResult)result).ExecuteAsync(httpContext.Object); - - // Assert - auth.Verify(); - } - private static IServiceProvider CreateServices(IAuthenticationService auth) { return new ServiceCollection() diff --git a/src/Mvc/Mvc.Core/test/VirtualFileActionResultTest .cs b/src/Mvc/Mvc.Core/test/VirtualFileActionResultTest .cs deleted file mode 100644 index ebc4f89f903e..000000000000 --- a/src/Mvc/Mvc.Core/test/VirtualFileActionResultTest .cs +++ /dev/null @@ -1,213 +0,0 @@ -// Copyright (c) .NET Foundation. 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 System.Threading.Tasks; -using Xunit; - -namespace Microsoft.AspNetCore.Mvc -{ - public class VirtualFileActionResultTest - { - [Fact] - public void Constructor_SetsFileName() - { - // Arrange - var path = Path.GetFullPath("helllo.txt"); - - // Act - var result = new VirtualFileResult(path, "text/plain"); - - // Assert - Assert.Equal(path, result.FileName); - } - - [Fact] - public void Constructor_SetsContentTypeAndParameters() - { - // Arrange - var path = Path.GetFullPath("helllo.txt"); - var contentType = "text/plain; charset=us-ascii; p1=p1-value"; - var expectedMediaType = contentType; - - // Act - var result = new VirtualFileResult(path, contentType); - - // Assert - Assert.Equal(path, result.FileName); - MediaTypeAssert.Equal(expectedMediaType, result.ContentType); - } - - [Theory] - [InlineData(0, 3, "File", 4)] - [InlineData(8, 13, "Result", 6)] - [InlineData(null, 4, "ts¡", 4)] - [InlineData(8, null, "ResultTestFile contents¡", 25)] - public async Task WriteFileAsync_WritesRangeRequested(long? start, long? end, string expectedString, long contentLength) - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseVirtualFileResultTest.WriteFileAsync_WritesRangeRequested( - start, - end, - expectedString, - contentLength, - action); - } - - [Fact] - public async Task WriteFileAsync_IfRangeHeaderValid_WritesRequestedRange() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseVirtualFileResultTest.WriteFileAsync_IfRangeHeaderValid_WritesRequestedRange(action); - } - - [Fact] - public async Task WriteFileAsync_RangeProcessingNotEnabled_RangeRequestedIgnored() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseVirtualFileResultTest - .WriteFileAsync_RangeProcessingNotEnabled_RangeRequestedIgnored(action); - } - - [Fact] - public async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestedIgnored() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - await BaseVirtualFileResultTest.WriteFileAsync_IfRangeHeaderInvalid_RangeRequestedIgnored(action); - } - - [Theory] - [InlineData("0-5")] - [InlineData("bytes = ")] - [InlineData("bytes = 1-4, 5-11")] - public async Task WriteFileAsync_RangeHeaderMalformed_RangeRequestIgnored(string rangeString) - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - await BaseVirtualFileResultTest.WriteFileAsync_RangeHeaderMalformed_RangeRequestIgnored(rangeString, action); - } - - [Theory] - [InlineData("bytes = 35-36")] - [InlineData("bytes = -0")] - public async Task WriteFileAsync_RangeRequestedNotSatisfiable(string rangeString) - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - await BaseVirtualFileResultTest.WriteFileAsync_RangeRequestedNotSatisfiable(rangeString, action); - } - - [Fact] - public async Task WriteFileAsync_RangeRequested_PreconditionFailed() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - await BaseVirtualFileResultTest.WriteFileAsync_RangeRequested_PreconditionFailed(action); - } - - [Fact] - public async Task WriteFileAsync_RangeRequested_NotModified() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseVirtualFileResultTest.WriteFileAsync_RangeRequested_NotModified(action); - } - - [Fact] - public async Task ExecuteResultAsync_FallsBackToWebRootFileProvider_IfNoFileProviderIsPresent() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseVirtualFileResultTest - .ExecuteResultAsync_FallsBackToWebRootFileProvider_IfNoFileProviderIsPresent(action); - } - - [Fact] - public async Task ExecuteResultAsync_CallsSendFileAsync_IfIHttpSendFilePresent() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseVirtualFileResultTest - .ExecuteResultAsync_CallsSendFileAsync_IfIHttpSendFilePresent(action); - } - - [Theory] - [InlineData(0, 3, "File", 4)] - [InlineData(8, 13, "Result", 6)] - [InlineData(null, 3, "ts¡", 3)] - [InlineData(8, null, "ResultTestFile contents¡", 25)] - public async Task ExecuteResultAsync_CallsSendFileAsyncWithRequestedRange_IfIHttpSendFilePresent(long? start, long? end, string expectedString, long contentLength) - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseVirtualFileResultTest.ExecuteResultAsync_CallsSendFileAsyncWithRequestedRange_IfIHttpSendFilePresent( - start, - end, - expectedString, - contentLength, - action); - } - - [Fact] - public async Task ExecuteResultAsync_SetsSuppliedContentTypeAndEncoding() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseVirtualFileResultTest.ExecuteResultAsync_SetsSuppliedContentTypeAndEncoding(action); - } - - [Fact] - public async Task ExecuteResultAsync_ReturnsFileContentsForRelativePaths() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseVirtualFileResultTest.ExecuteResultAsync_ReturnsFileContentsForRelativePaths(action); - } - - [Theory] - [InlineData("FilePathResultTestFile.txt")] - [InlineData("TestFiles/FilePathResultTestFile.txt")] - [InlineData("TestFiles/../FilePathResultTestFile.txt")] - [InlineData("TestFiles\\FilePathResultTestFile.txt")] - [InlineData("TestFiles\\..\\FilePathResultTestFile.txt")] - [InlineData(@"\\..//?><|""&@#\c:\..\? /..txt")] - public async Task ExecuteResultAsync_ReturnsFiles_ForDifferentPaths(string path) - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseVirtualFileResultTest - .ExecuteResultAsync_ReturnsFiles_ForDifferentPaths(path, action); - } - - [Theory] - [InlineData("~/FilePathResultTestFile.txt")] - [InlineData("~/TestFiles/FilePathResultTestFile.txt")] - [InlineData("~/TestFiles/../FilePathResultTestFile.txt")] - [InlineData("~/TestFiles\\..\\FilePathResultTestFile.txt")] - [InlineData(@"~~~~\\..//?>~<|""&@#\c:\..\? /..txt~~~")] - public async Task ExecuteResultAsync_TrimsTilde_BeforeInvokingFileProvider(string path) - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseVirtualFileResultTest - .ExecuteResultAsync_TrimsTilde_BeforeInvokingFileProvider(path, action); - } - - [Fact] - public async Task ExecuteResultAsync_WorksWithNonDiskBasedFiles() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseVirtualFileResultTest.ExecuteResultAsync_WorksWithNonDiskBasedFiles(action); - } - - [Fact] - public async Task ExecuteResultAsync_ThrowsFileNotFound_IfFileProviderCanNotFindTheFile() - { - var action = new Func(async (result, context) => await result.ExecuteResultAsync(context)); - - await BaseVirtualFileResultTest.ExecuteResultAsync_ThrowsFileNotFound_IfFileProviderCanNotFindTheFile(action); - } - } -} diff --git a/src/Mvc/Mvc.Core/test/VirtualFileResultTest.cs b/src/Mvc/Mvc.Core/test/VirtualFileResultTest.cs index 04a1f14037d4..e0a3d46c502f 100644 --- a/src/Mvc/Mvc.Core/test/VirtualFileResultTest.cs +++ b/src/Mvc/Mvc.Core/test/VirtualFileResultTest.cs @@ -2,186 +2,104 @@ // 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.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Internal; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Net.Http.Headers; +using Moq; using Xunit; namespace Microsoft.AspNetCore.Mvc { - public class VirtualFileResultTest + public class VirtualFileResultTest : VirtualFileResultTestBase { - [Theory] - [InlineData(0, 3, "File", 4)] - [InlineData(8, 13, "Result", 6)] - [InlineData(null, 4, "ts¡", 4)] - [InlineData(8, null, "ResultTestFile contents¡", 25)] - public async Task WriteFileAsync_WritesRangeRequested(long? start, long? end, string expectedString, long contentLength) - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BaseVirtualFileResultTest.WriteFileAsync_WritesRangeRequested( - start, - end, - expectedString, - contentLength, - action); - } - - [Fact] - public async Task WriteFileAsync_IfRangeHeaderValid_WritesRequestedRange() - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BaseVirtualFileResultTest.WriteFileAsync_IfRangeHeaderValid_WritesRequestedRange(action); - } - [Fact] - public async Task WriteFileAsync_RangeProcessingNotEnabled_RangeRequestedIgnored() + public void Constructor_SetsFileName() { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BaseVirtualFileResultTest.WriteFileAsync_RangeProcessingNotEnabled_RangeRequestedIgnored(action); - } - - [Fact] - public async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestedIgnored() - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BaseVirtualFileResultTest.WriteFileAsync_IfRangeHeaderInvalid_RangeRequestedIgnored(action); - } - - [Theory] - [InlineData("0-5")] - [InlineData("bytes = ")] - [InlineData("bytes = 1-4, 5-11")] - public async Task WriteFileAsync_RangeHeaderMalformed_RangeRequestIgnored(string rangeString) - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BaseVirtualFileResultTest.WriteFileAsync_RangeHeaderMalformed_RangeRequestIgnored(rangeString, action); - } - - [Theory] - [InlineData("bytes = 35-36")] - [InlineData("bytes = -0")] - public async Task WriteFileAsync_RangeRequestedNotSatisfiable(string rangeString) - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BaseVirtualFileResultTest.WriteFileAsync_RangeRequestedNotSatisfiable(rangeString, action); - } + // Arrange + var path = Path.GetFullPath("helllo.txt"); - [Fact] - public async Task WriteFileAsync_RangeRequested_PreconditionFailed() - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); + // Act + var result = new VirtualFileResult(path, "text/plain"); - await BaseVirtualFileResultTest.WriteFileAsync_RangeRequested_PreconditionFailed(action); + // Assert + Assert.Equal(path, result.FileName); } [Fact] - public async Task WriteFileAsync_RangeRequested_NotModified() + public void Constructor_SetsContentTypeAndParameters() { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BaseVirtualFileResultTest.WriteFileAsync_RangeRequested_NotModified(action); - } + // Arrange + var path = Path.GetFullPath("helllo.txt"); + var contentType = "text/plain; charset=us-ascii; p1=p1-value"; + var expectedMediaType = contentType; - [Fact] - public async Task ExecuteResultAsync_FallsBackToWebRootFileProvider_IfNoFileProviderIsPresent() - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); + // Act + var result = new VirtualFileResult(path, contentType); - await BaseVirtualFileResultTest - .ExecuteResultAsync_FallsBackToWebRootFileProvider_IfNoFileProviderIsPresent(action); + // Assert + Assert.Equal(path, result.FileName); + MediaTypeAssert.Equal(expectedMediaType, result.ContentType); } [Fact] - public async Task ExecuteResultAsync_CallsSendFileAsync_IfIHttpSendFilePresent() + public void GetFileProvider_ReturnsFileProviderFromWebHostEnvironment() { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); + // Arrange + var webHostFileProvider = Mock.Of(); + var webHostEnvironment = Mock.Of(e => e.WebRootFileProvider == webHostFileProvider); - await BaseVirtualFileResultTest - .ExecuteResultAsync_CallsSendFileAsync_IfIHttpSendFilePresent(action); - } - - [Theory] - [InlineData(0, 3, "File", 4)] - [InlineData(8, 13, "Result", 6)] - [InlineData(null, 3, "ts¡", 3)] - [InlineData(8, null, "ResultTestFile contents¡", 25)] - public async Task ExecuteResultAsync_CallsSendFileAsyncWithRequestedRange_IfIHttpSendFilePresent(long? start, long? end, string expectedString, long contentLength) - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BaseVirtualFileResultTest.ExecuteResultAsync_CallsSendFileAsyncWithRequestedRange_IfIHttpSendFilePresent( - start, - end, - expectedString, - contentLength, - action); - } + var result = new VirtualFileResult("some-path", "text/plain"); - [Fact] - public async Task ExecuteResultAsync_SetsSuppliedContentTypeAndEncoding() - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); + // Act + var fileProvider = VirtualFileResultExecutor.GetFileProvider(result, webHostEnvironment); - await BaseVirtualFileResultTest.ExecuteResultAsync_SetsSuppliedContentTypeAndEncoding(action); + // Assert + Assert.Same(webHostFileProvider, fileProvider); } [Fact] - public async Task ExecuteResultAsync_ReturnsFileContentsForRelativePaths() + public void GetFileProvider_ReturnsFileProviderFromResult() { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); + // Arrange + var webHostFileProvider = Mock.Of(); + var fileProvider = Mock.Of(); + var webHostEnvironment = Mock.Of(e => e.WebRootFileProvider == webHostFileProvider); - await BaseVirtualFileResultTest.ExecuteResultAsync_ReturnsFileContentsForRelativePaths(action); - } + var result = new VirtualFileResult("some-path", "text/plain") { FileProvider = fileProvider }; - [Theory] - [InlineData("FilePathResultTestFile.txt")] - [InlineData("TestFiles/FilePathResultTestFile.txt")] - [InlineData("TestFiles/../FilePathResultTestFile.txt")] - [InlineData("TestFiles\\FilePathResultTestFile.txt")] - [InlineData("TestFiles\\..\\FilePathResultTestFile.txt")] - [InlineData(@"\\..//?><|""&@#\c:\..\? /..txt")] - public async Task ExecuteResultAsync_ReturnsFiles_ForDifferentPaths(string path) - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); + // Act + var actual = VirtualFileResultExecutor.GetFileProvider(result, webHostEnvironment); - await BaseVirtualFileResultTest - .ExecuteResultAsync_ReturnsFiles_ForDifferentPaths(path, action); + // Assert + Assert.Same(fileProvider, actual); } - [Theory] - [InlineData("~/FilePathResultTestFile.txt")] - [InlineData("~/TestFiles/FilePathResultTestFile.txt")] - [InlineData("~/TestFiles/../FilePathResultTestFile.txt")] - [InlineData("~/TestFiles\\..\\FilePathResultTestFile.txt")] - [InlineData(@"~~~~\\..//?>~<|""&@#\c:\..\? /..txt~~~")] - public async Task ExecuteResultAsync_TrimsTilde_BeforeInvokingFileProvider(string path) + protected override Task ExecuteAsync(HttpContext httpContext, string path, string contentType, DateTimeOffset? lastModified = null, EntityTagHeaderValue entityTag = null, bool enableRangeProcessing = false) { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BaseVirtualFileResultTest - .ExecuteResultAsync_TrimsTilde_BeforeInvokingFileProvider(path, action); - } + var webHostEnvironment = httpContext.RequestServices.GetRequiredService(); + httpContext.RequestServices = new ServiceCollection() + .AddSingleton(webHostEnvironment) + .AddTransient, VirtualFileResultExecutor>() + .AddTransient() + .BuildServiceProvider(); - [Fact] - public async Task ExecuteResultAsync_WorksWithNonDiskBasedFiles() - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); - - await BaseVirtualFileResultTest.ExecuteResultAsync_WorksWithNonDiskBasedFiles(action); - } - - [Fact] - public async Task ExecuteResultAsync_ThrowsFileNotFound_IfFileProviderCanNotFindTheFile() - { - var action = new Func(async (result, context) => await ((IResult)result).ExecuteAsync(context)); + var actionContext = new ActionContext(httpContext, new(), new()); + var result = new VirtualFileResult(path, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + EnableRangeProcessing = enableRangeProcessing, + }; - await BaseVirtualFileResultTest.ExecuteResultAsync_ThrowsFileNotFound_IfFileProviderCanNotFindTheFile(action); + return result.ExecuteResultAsync(actionContext); } } } diff --git a/src/Mvc/Mvc.NewtonsoftJson/src/Microsoft.AspNetCore.Mvc.NewtonsoftJson.csproj b/src/Mvc/Mvc.NewtonsoftJson/src/Microsoft.AspNetCore.Mvc.NewtonsoftJson.csproj index 57e3effb81a7..73355eca919c 100644 --- a/src/Mvc/Mvc.NewtonsoftJson/src/Microsoft.AspNetCore.Mvc.NewtonsoftJson.csproj +++ b/src/Mvc/Mvc.NewtonsoftJson/src/Microsoft.AspNetCore.Mvc.NewtonsoftJson.csproj @@ -22,7 +22,7 @@ - + diff --git a/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonResultExecutor.cs b/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonResultExecutor.cs index 2091b8494a9d..9e29fda66fe2 100644 --- a/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonResultExecutor.cs +++ b/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonResultExecutor.cs @@ -5,6 +5,7 @@ using System.Buffers; using System.Text; using System.Threading.Tasks; +using Microsoft.AspNetCore.Internal; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.WebUtilities; @@ -100,7 +101,8 @@ public async Task ExecuteAsync(ActionContext context, JsonResult result) ResponseContentTypeHelper.ResolveContentTypeAndEncoding( result.ContentType, response.ContentType, - DefaultContentType, + (DefaultContentType, Encoding.UTF8), + MediaType.GetEncoding, out var resolvedContentType, out var resolvedContentTypeEncoding); diff --git a/src/Mvc/Mvc.ViewFeatures/src/ViewComponentResultExecutor.cs b/src/Mvc/Mvc.ViewFeatures/src/ViewComponentResultExecutor.cs index d31b1d61830d..2a6595cc9688 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/ViewComponentResultExecutor.cs +++ b/src/Mvc/Mvc.ViewFeatures/src/ViewComponentResultExecutor.cs @@ -4,9 +4,11 @@ #nullable enable using System; +using System.Text; using System.Text.Encodings.Web; using System.Threading.Tasks; using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Internal; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -112,7 +114,8 @@ public virtual async Task ExecuteAsync(ActionContext context, ViewComponentResul ResponseContentTypeHelper.ResolveContentTypeAndEncoding( result.ContentType, response.ContentType, - ViewExecutor.DefaultContentType, + (ViewExecutor.DefaultContentType, Encoding.UTF8), + MediaType.GetEncoding, out var resolvedContentType, out var resolvedContentTypeEncoding); diff --git a/src/Mvc/Mvc.ViewFeatures/src/ViewExecutor.cs b/src/Mvc/Mvc.ViewFeatures/src/ViewExecutor.cs index 2c9e7fd860ee..2f5bba01ae8b 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/ViewExecutor.cs +++ b/src/Mvc/Mvc.ViewFeatures/src/ViewExecutor.cs @@ -6,7 +6,9 @@ using System; using System.Diagnostics; using System.IO; +using System.Text; using System.Threading.Tasks; +using Microsoft.AspNetCore.Internal; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -222,7 +224,8 @@ protected async Task ExecuteAsync( ResponseContentTypeHelper.ResolveContentTypeAndEncoding( contentType, response.ContentType, - DefaultContentType, + (DefaultContentType, Encoding.UTF8), + MediaType.GetEncoding, out var resolvedContentType, out var resolvedContentTypeEncoding); diff --git a/src/Mvc/MvcNoDeps.slnf b/src/Mvc/MvcNoDeps.slnf index b393e2b59c4f..5bcf63dd8a47 100644 --- a/src/Mvc/MvcNoDeps.slnf +++ b/src/Mvc/MvcNoDeps.slnf @@ -64,6 +64,8 @@ "src\\Mvc\\test\\WebSites\\RazorWebSite\\RazorWebSite.csproj", "src\\Mvc\\test\\WebSites\\RoutingWebSite\\Mvc.RoutingWebSite.csproj", "src\\Mvc\\test\\WebSites\\SecurityWebSite\\SecurityWebSite.csproj", + "src\\Mvc\\test\\WebSites\\SimpleWebSiteWithWebApplicationBuilderException\\SimpleWebSiteWithWebApplicationBuilderException.csproj", + "src\\Mvc\\test\\WebSites\\SimpleWebSiteWithWebApplicationBuilder\\SimpleWebSiteWithWebApplicationBuilder.csproj", "src\\Mvc\\test\\WebSites\\SimpleWebSite\\SimpleWebSite.csproj", "src\\Mvc\\test\\WebSites\\TagHelpersWebSite\\TagHelpersWebSite.csproj", "src\\Mvc\\test\\WebSites\\VersioningWebSite\\VersioningWebSite.csproj", diff --git a/src/Mvc/test/Mvc.FunctionalTests/SimpleWithWebApplicationBuilderTests.cs b/src/Mvc/test/Mvc.FunctionalTests/SimpleWithWebApplicationBuilderTests.cs index a677f34009d6..27fc44c124a5 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/SimpleWithWebApplicationBuilderTests.cs +++ b/src/Mvc/test/Mvc.FunctionalTests/SimpleWithWebApplicationBuilderTests.cs @@ -1,7 +1,6 @@ // Copyright (c) .NET Foundation. 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; @@ -30,5 +29,72 @@ public async Task HelloWorld() // Assert Assert.Equal(expected, content); } + + [Fact] + public async Task JsonResult_Works() + { + // Arrange + var expected = "{\"name\":\"John\",\"age\":42}"; + + // Act + var response = await Client.GetAsync("/json"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal(expected, content); + } + + [Fact] + public async Task OkObjectResult_Works() + { + // Arrange + var expected = "{\"name\":\"John\",\"age\":42}"; + + // Act + var response = await Client.GetAsync("/ok-object"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal(expected, content); + } + + [Fact] + public async Task AcceptedObjectResult_Works() + { + // Arrange + var expected = "{\"name\":\"John\",\"age\":42}"; + + // Act + var response = await Client.GetAsync("/accepted-object"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.Accepted); + Assert.Equal("/ok-object", response.Headers.Location.ToString()); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal(expected, content); + } + + [Fact] + public async Task ActionReturningMoreThanOneResult_NotFound() + { + // Act + var response = await Client.GetAsync("/many-results?id=-1"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.NotFound); + } + + [Fact] + public async Task ActionReturningMoreThanOneResult_Found() + { + // Act + var response = await Client.GetAsync("/many-results?id=7"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.MovedPermanently); + Assert.Equal("/json", response.Headers.Location.ToString()); + } } } diff --git a/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilder/Program.cs b/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilder/Program.cs index 09c9e72998ab..e185a78c7db9 100644 --- a/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilder/Program.cs +++ b/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilder/Program.cs @@ -1,11 +1,30 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System; using Microsoft.AspNetCore.Builder; +using static Microsoft.AspNetCore.Http.Results; var app = WebApplication.Create(args); -app.MapGet("/", (Func)(() => "Hello World")); +app.MapGet("/", () => "Hello World"); + +app.MapGet("/json", () => Json(new Person("John", 42))); + +app.MapGet("/ok-object", () => Ok(new Person("John", 42))); + +app.MapGet("/accepted-object", () => Accepted("/ok-object", new Person("John", 42))); + +app.MapGet("/many-results", (int id) => +{ + if (id == -1) + { + return NotFound(); + } + + return RedirectPermanent("/json"); +}); app.Run(); + + +record Person(string Name, int Age); diff --git a/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilder/SimpleWebSiteWithWebApplicationBuilder.csproj b/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilder/SimpleWebSiteWithWebApplicationBuilder.csproj index 58eb21f2db2d..6a226b6d428f 100644 --- a/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilder/SimpleWebSiteWithWebApplicationBuilder.csproj +++ b/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilder/SimpleWebSiteWithWebApplicationBuilder.csproj @@ -2,9 +2,11 @@ $(DefaultNetCoreTargetFramework) + Preview + diff --git a/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/SimpleWebSiteWithWebApplicationBuilderException.csproj b/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/SimpleWebSiteWithWebApplicationBuilderException.csproj index 58eb21f2db2d..b98dad5ccb6f 100644 --- a/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/SimpleWebSiteWithWebApplicationBuilderException.csproj +++ b/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/SimpleWebSiteWithWebApplicationBuilderException.csproj @@ -2,6 +2,7 @@ $(DefaultNetCoreTargetFramework) + Preview diff --git a/src/Mvc/Mvc.Core/src/Formatters/ResponseContentTypeHelper.cs b/src/Shared/ResponseContentTypeHelper.cs similarity index 68% rename from src/Mvc/Mvc.Core/src/Formatters/ResponseContentTypeHelper.cs rename to src/Shared/ResponseContentTypeHelper.cs index 3e941e9199ca..19b33346fd11 100644 --- a/src/Mvc/Mvc.Core/src/Formatters/ResponseContentTypeHelper.cs +++ b/src/Shared/ResponseContentTypeHelper.cs @@ -1,11 +1,12 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System.Diagnostics; +using System; using System.Text; using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; -namespace Microsoft.AspNetCore.Mvc.Formatters +namespace Microsoft.AspNetCore.Internal { internal static class ResponseContentTypeHelper { @@ -22,29 +23,21 @@ internal static class ResponseContentTypeHelper /// encoding is used to write the response and the ContentType header is set to be "text/plain" without any /// "charset" information. /// - /// ContentType set on the action result - /// property set - /// on - /// The default content type of the action result. - /// The content type to be used for the response content type header - /// Encoding to be used for writing the response public static void ResolveContentTypeAndEncoding( string? actionResultContentType, - string httpResponseContentType, - string defaultContentType, + string? httpResponseContentType, + (string defaultContentType, Encoding defaultEncoding) @default, + Func getEncoding, out string resolvedContentType, out Encoding resolvedContentTypeEncoding) { - Debug.Assert(defaultContentType != null); - - var defaultContentTypeEncoding = MediaType.GetEncoding(defaultContentType); - Debug.Assert(defaultContentTypeEncoding != null); + var (defaultContentType, defaultContentTypeEncoding) = @default; // 1. User sets the ContentType property on the action result if (actionResultContentType != null) { resolvedContentType = actionResultContentType; - var actionResultEncoding = MediaType.GetEncoding(actionResultContentType); + var actionResultEncoding = getEncoding(actionResultContentType); resolvedContentTypeEncoding = actionResultEncoding ?? defaultContentTypeEncoding; return; } @@ -52,7 +45,7 @@ public static void ResolveContentTypeAndEncoding( // 2. User sets the ContentType property on the http response directly if (!string.IsNullOrEmpty(httpResponseContentType)) { - var mediaTypeEncoding = MediaType.GetEncoding(httpResponseContentType); + var mediaTypeEncoding = getEncoding(httpResponseContentType); if (mediaTypeEncoding != null) { resolvedContentType = httpResponseContentType; @@ -71,5 +64,15 @@ public static void ResolveContentTypeAndEncoding( resolvedContentType = defaultContentType; resolvedContentTypeEncoding = defaultContentTypeEncoding; } + + public static Encoding? GetEncoding(string mediaType) + { + if (MediaTypeHeaderValue.TryParse(mediaType, out var parsed)) + { + return parsed.Encoding; + } + + return default; + } } } diff --git a/src/Shared/ResultsHelpers/FileResultHelper.cs b/src/Shared/ResultsHelpers/FileResultHelper.cs new file mode 100644 index 000000000000..fb44351d4359 --- /dev/null +++ b/src/Shared/ResultsHelpers/FileResultHelper.cs @@ -0,0 +1,403 @@ +// Copyright (c) .NET Foundation. 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.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Http.Headers; +using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Internal +{ + internal static partial class FileResultHelper + { + private const string AcceptRangeHeaderValue = "bytes"; + + internal enum PreconditionState + { + Unspecified, + NotModified, + ShouldProcess, + PreconditionFailed + } + + internal static async Task WriteFileAsync(HttpContext context, Stream fileStream, RangeItemHeaderValue? range, long rangeLength) + { + const int BufferSize = 64 * 1024; + var outputStream = context.Response.Body; + using (fileStream) + { + try + { + if (range == null) + { + await StreamCopyOperation.CopyToAsync(fileStream, outputStream, count: null, bufferSize: 64 * 1024, cancel: context.RequestAborted); + } + else + { + fileStream.Seek(range.From!.Value, SeekOrigin.Begin); + await StreamCopyOperation.CopyToAsync(fileStream, outputStream, rangeLength, BufferSize, context.RequestAborted); + } + } + catch (OperationCanceledException) + { + // Don't throw this exception, it's most likely caused by the client disconnecting. + // However, if it was cancelled for any other reason we need to prevent empty responses. + context.Abort(); + } + } + } + + internal static (RangeItemHeaderValue? range, long rangeLength, bool serveBody) SetHeadersAndLog( + HttpContext httpContext, + in FileResultInfo result, + long? fileLength, + bool enableRangeProcessing, + DateTimeOffset? lastModified, + EntityTagHeaderValue? etag, + ILogger logger) + { + var request = httpContext.Request; + var httpRequestHeaders = request.GetTypedHeaders(); + + // Since the 'Last-Modified' and other similar http date headers are rounded down to whole seconds, + // round down current file's last modified to whole seconds for correct comparison. + if (lastModified.HasValue) + { + lastModified = RoundDownToWholeSeconds(lastModified.Value); + } + + var preconditionState = GetPreconditionState(httpRequestHeaders, lastModified, etag, logger); + + var response = httpContext.Response; + SetLastModifiedAndEtagHeaders(response, lastModified, etag); + + // Short circuit if the preconditional headers process to 304 (NotModified) or 412 (PreconditionFailed) + if (preconditionState == PreconditionState.NotModified) + { + response.StatusCode = StatusCodes.Status304NotModified; + return (range: null, rangeLength: 0, serveBody: false); + } + else if (preconditionState == PreconditionState.PreconditionFailed) + { + response.StatusCode = StatusCodes.Status412PreconditionFailed; + return (range: null, rangeLength: 0, serveBody: false); + } + + + response.ContentType = result.ContentType; + SetContentDispositionHeader(httpContext, in result); + + if (fileLength.HasValue) + { + // Assuming the request is not a range request, and the response body is not empty, the Content-Length header is set to + // the length of the entire file. + // If the request is a valid range request, this header is overwritten with the length of the range as part of the + // range processing (see method SetContentLength). + + response.ContentLength = fileLength.Value; + + // Handle range request + if (enableRangeProcessing) + { + SetAcceptRangeHeader(response); + + // If the request method is HEAD or GET, PreconditionState is Unspecified or ShouldProcess, and IfRange header is valid, + // range should be processed and Range headers should be set + if ((HttpMethods.IsHead(request.Method) || HttpMethods.IsGet(request.Method)) + && (preconditionState == PreconditionState.Unspecified || preconditionState == PreconditionState.ShouldProcess) + && (IfRangeValid(httpRequestHeaders, lastModified, etag, logger))) + { + return SetRangeHeaders(httpContext, httpRequestHeaders, fileLength.Value, logger); + } + } + else + { + Log.NotEnabledForRangeProcessing(logger); + } + } + + return (range: null, rangeLength: 0, serveBody: !HttpMethods.IsHead(request.Method)); + } + + internal static bool IfRangeValid( + RequestHeaders httpRequestHeaders, + DateTimeOffset? lastModified, + EntityTagHeaderValue? etag, + ILogger logger) + { + // 14.27 If-Range + var ifRange = httpRequestHeaders.IfRange; + if (ifRange != null) + { + // If the validator given in the If-Range header field matches the + // current validator for the selected representation of the target + // resource, then the server SHOULD process the Range header field as + // requested. If the validator does not match, the server MUST ignore + // the Range header field. + if (ifRange.LastModified.HasValue) + { + if (lastModified.HasValue && lastModified > ifRange.LastModified) + { + Log.IfRangeLastModifiedPreconditionFailed(logger, lastModified, ifRange.LastModified); + return false; + } + } + else if (etag != null && ifRange.EntityTag != null && !ifRange.EntityTag.Compare(etag, useStrongComparison: true)) + { + Log.IfRangeETagPreconditionFailed(logger, etag, ifRange.EntityTag); + return false; + } + } + + return true; + } + + internal static PreconditionState GetPreconditionState( + RequestHeaders httpRequestHeaders, + DateTimeOffset? lastModified, + EntityTagHeaderValue? etag, + ILogger logger) + { + var ifMatchState = PreconditionState.Unspecified; + var ifNoneMatchState = PreconditionState.Unspecified; + var ifModifiedSinceState = PreconditionState.Unspecified; + var ifUnmodifiedSinceState = PreconditionState.Unspecified; + + // 14.24 If-Match + var ifMatch = httpRequestHeaders.IfMatch; + if (etag != null) + { + ifMatchState = GetEtagMatchState( + useStrongComparison: true, + etagHeader: ifMatch, + etag: etag, + matchFoundState: PreconditionState.ShouldProcess, + matchNotFoundState: PreconditionState.PreconditionFailed); + + if (ifMatchState == PreconditionState.PreconditionFailed) + { + Log.IfMatchPreconditionFailed(logger, etag); + } + } + + // 14.26 If-None-Match + var ifNoneMatch = httpRequestHeaders.IfNoneMatch; + if (etag != null) + { + ifNoneMatchState = GetEtagMatchState( + useStrongComparison: false, + etagHeader: ifNoneMatch, + etag: etag, + matchFoundState: PreconditionState.NotModified, + matchNotFoundState: PreconditionState.ShouldProcess); + } + + var now = RoundDownToWholeSeconds(DateTimeOffset.UtcNow); + + // 14.25 If-Modified-Since + var ifModifiedSince = httpRequestHeaders.IfModifiedSince; + if (lastModified.HasValue && ifModifiedSince.HasValue && ifModifiedSince <= now) + { + var modified = ifModifiedSince < lastModified; + ifModifiedSinceState = modified ? PreconditionState.ShouldProcess : PreconditionState.NotModified; + } + + // 14.28 If-Unmodified-Since + var ifUnmodifiedSince = httpRequestHeaders.IfUnmodifiedSince; + if (lastModified.HasValue && ifUnmodifiedSince.HasValue && ifUnmodifiedSince <= now) + { + var unmodified = ifUnmodifiedSince >= lastModified; + ifUnmodifiedSinceState = unmodified ? PreconditionState.ShouldProcess : PreconditionState.PreconditionFailed; + + if (ifUnmodifiedSinceState == PreconditionState.PreconditionFailed) + { + Log.IfUnmodifiedSincePreconditionFailed(logger, lastModified, ifUnmodifiedSince); + } + } + + var state = GetMaxPreconditionState(ifMatchState, ifNoneMatchState, ifModifiedSinceState, ifUnmodifiedSinceState); + return state; + } + + private static PreconditionState GetEtagMatchState( + bool useStrongComparison, + IList etagHeader, + EntityTagHeaderValue etag, + PreconditionState matchFoundState, + PreconditionState matchNotFoundState) + { + if (etagHeader?.Count > 0) + { + var state = matchNotFoundState; + foreach (var entityTag in etagHeader) + { + if (entityTag.Equals(EntityTagHeaderValue.Any) || entityTag.Compare(etag, useStrongComparison)) + { + state = matchFoundState; + break; + } + } + + return state; + } + + return PreconditionState.Unspecified; + } + + private static (RangeItemHeaderValue? range, long rangeLength, bool serveBody) SetRangeHeaders( + HttpContext httpContext, + RequestHeaders httpRequestHeaders, + long fileLength, + ILogger logger) + { + var response = httpContext.Response; + var httpResponseHeaders = response.GetTypedHeaders(); + var serveBody = !HttpMethods.IsHead(httpContext.Request.Method); + + // Range may be null for empty range header, invalid ranges, parsing errors, multiple ranges + // and when the file length is zero. + var (isRangeRequest, range) = RangeHelper.ParseRange( + httpContext, + httpRequestHeaders, + fileLength, + logger); + + if (!isRangeRequest) + { + return (range: null, rangeLength: 0, serveBody); + } + + // Requested range is not satisfiable + if (range == null) + { + // 14.16 Content-Range - A server sending a response with status code 416 (Requested range not satisfiable) + // SHOULD include a Content-Range field with a byte-range-resp-spec of "*". The instance-length specifies + // the current length of the selected resource. e.g. */length + response.StatusCode = StatusCodes.Status416RangeNotSatisfiable; + httpResponseHeaders.ContentRange = new ContentRangeHeaderValue(fileLength); + response.ContentLength = 0; + + return (range: null, rangeLength: 0, serveBody: false); + } + + response.StatusCode = StatusCodes.Status206PartialContent; + httpResponseHeaders.ContentRange = new ContentRangeHeaderValue( + range.From!.Value, + range.To!.Value, + fileLength); + + // Overwrite the Content-Length header for valid range requests with the range length. + var rangeLength = SetContentLength(response, range); + + return (range, rangeLength, serveBody); + } + + private static long SetContentLength(HttpResponse response, RangeItemHeaderValue range) + { + var start = range.From!.Value; + var end = range.To!.Value; + var length = end - start + 1; + response.ContentLength = length; + return length; + } + + private static void SetContentDispositionHeader(HttpContext httpContext, in FileResultInfo result) + { + if (!string.IsNullOrEmpty(result.FileDownloadName)) + { + // From RFC 2183, Sec. 2.3: + // The sender may want to suggest a filename to be used if the entity is + // detached and stored in a separate file. If the receiving MUA writes + // the entity to a file, the suggested filename should be used as a + // basis for the actual filename, where possible. + var contentDisposition = new ContentDispositionHeaderValue("attachment"); + contentDisposition.SetHttpFileName(result.FileDownloadName); + httpContext.Response.Headers.ContentDisposition = contentDisposition.ToString(); + } + } + + private static void SetLastModifiedAndEtagHeaders(HttpResponse response, DateTimeOffset? lastModified, EntityTagHeaderValue? etag) + { + var httpResponseHeaders = response.GetTypedHeaders(); + if (lastModified.HasValue) + { + httpResponseHeaders.LastModified = lastModified; + } + if (etag != null) + { + httpResponseHeaders.ETag = etag; + } + } + + private static void SetAcceptRangeHeader(HttpResponse response) + { + response.Headers.AcceptRanges = AcceptRangeHeaderValue; + } + + private static PreconditionState GetMaxPreconditionState(params PreconditionState[] states) + { + var max = PreconditionState.Unspecified; + for (var i = 0; i < states.Length; i++) + { + if (states[i] > max) + { + max = states[i]; + } + } + + return max; + } + + private static DateTimeOffset RoundDownToWholeSeconds(DateTimeOffset dateTimeOffset) + { + var ticksToRemove = dateTimeOffset.Ticks % TimeSpan.TicksPerSecond; + return dateTimeOffset.Subtract(TimeSpan.FromTicks(ticksToRemove)); + } + + internal static partial class Log + { + [LoggerMessage(17, LogLevel.Debug, "Writing the requested range of bytes to the body.", EventName = "WritingRangeToBody")] + public static partial void WritingRangeToBody(ILogger logger); + + [LoggerMessage(34, LogLevel.Debug, + "Current request's If-Match header check failed as the file's current etag '{CurrentETag}' does not match with any of the supplied etags.", + EventName = "IfMatchPreconditionFailed")] + public static partial void IfMatchPreconditionFailed(ILogger logger, EntityTagHeaderValue currentETag); + + [LoggerMessage(35, LogLevel.Debug, + "Current request's If-Unmodified-Since header check failed as the file was modified (at '{lastModified}') after the If-Unmodified-Since date '{IfUnmodifiedSinceDate}'.", + EventName = "IfUnmodifiedSincePreconditionFailed")] + public static partial void IfUnmodifiedSincePreconditionFailed( + ILogger logger, + DateTimeOffset? lastModified, + DateTimeOffset? ifUnmodifiedSinceDate); + + [LoggerMessage(36, LogLevel.Debug, + "Could not serve range as the file was modified (at {LastModified}) after the if-Range's last modified date '{IfRangeLastModified}'.", + EventName = "IfRangeLastModifiedPreconditionFailed")] + public static partial void IfRangeLastModifiedPreconditionFailed( + ILogger logger, + DateTimeOffset? lastModified, + DateTimeOffset? IfRangeLastModified); + + [LoggerMessage(37, LogLevel.Debug, + "Could not serve range as the file's current etag '{CurrentETag}' does not match the If-Range etag '{IfRangeETag}'.", + EventName = "IfRangeETagPreconditionFailed")] + public static partial void IfRangeETagPreconditionFailed( + ILogger logger, + EntityTagHeaderValue currentETag, + EntityTagHeaderValue IfRangeETag); + + [LoggerMessage(38, LogLevel.Debug, + "The file result has not been enabled for processing range requests. To enable it, set the EnableRangeProcessing property on the result to 'true'.", + EventName = "NotEnabledForRangeProcessing")] + public static partial void NotEnabledForRangeProcessing(ILogger logger); + } + + } +} diff --git a/src/Shared/ResultsHelpers/FileResultInfo.cs b/src/Shared/ResultsHelpers/FileResultInfo.cs new file mode 100644 index 000000000000..59bd4d05d72d --- /dev/null +++ b/src/Shared/ResultsHelpers/FileResultInfo.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Internal +{ + internal readonly struct FileResultInfo + { + public string ContentType { get; init; } + + public string FileDownloadName { get; init; } + + public DateTimeOffset? LastModified { get; init; } + + public EntityTagHeaderValue? EntityTag { get; init; } + + public bool EnableRangeProcessing { get; init; } + } +} diff --git a/src/Shared/ResultsHelpers/FileResultLogging.cs b/src/Shared/ResultsHelpers/FileResultLogging.cs new file mode 100644 index 000000000000..6f1b1c0a6d3d --- /dev/null +++ b/src/Shared/ResultsHelpers/FileResultLogging.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Internal +{ + internal interface IFileResultLogger + { + void IfUnmodifiedSincePreconditionFailed( + DateTimeOffset? lastModified, + DateTimeOffset? ifUnmodifiedSinceDate); + + void IfMatchPreconditionFailed(EntityTagHeaderValue etag); + + void NotEnabledForRangeProcessing(); + } +} diff --git a/src/Shared/ResultsHelpers/SharedUrlHelper.cs b/src/Shared/ResultsHelpers/SharedUrlHelper.cs new file mode 100644 index 000000000000..9f6732535a01 --- /dev/null +++ b/src/Shared/ResultsHelpers/SharedUrlHelper.cs @@ -0,0 +1,93 @@ +// Copyright (c) .NET Foundation. 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.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Internal +{ + internal static class SharedUrlHelper + { + [return: NotNullIfNotNull("contentPath")] + internal static string? Content(HttpContext httpContext, string? contentPath) + { + if (string.IsNullOrEmpty(contentPath)) + { + return null; + } + else if (contentPath[0] == '~') + { + var segment = new PathString(contentPath.Substring(1)); + var applicationPath = httpContext.Request.PathBase; + + var path = applicationPath.Add(segment); + Debug.Assert(path.HasValue); + return path.Value; + } + + return contentPath; + } + + internal static bool IsLocalUrl([NotNullWhen(true)] string? url) + { + if (string.IsNullOrEmpty(url)) + { + return false; + } + + // Allows "/" or "/foo" but not "//" or "/\". + if (url[0] == '/') + { + // url is exactly "/" + if (url.Length == 1) + { + return true; + } + + // url doesn't start with "//" or "/\" + if (url[1] != '/' && url[1] != '\\') + { + return !HasControlCharacter(url.AsSpan(1)); + } + + return false; + } + + // Allows "~/" or "~/foo" but not "~//" or "~/\". + if (url[0] == '~' && url.Length > 1 && url[1] == '/') + { + // url is exactly "~/" + if (url.Length == 2) + { + return true; + } + + // url doesn't start with "~//" or "~/\" + if (url[2] != '/' && url[2] != '\\') + { + return !HasControlCharacter(url.AsSpan(2)); + } + + return false; + } + + return false; + + static bool HasControlCharacter(ReadOnlySpan readOnlySpan) + { + // URLs may not contain ASCII control characters. + for (var i = 0; i < readOnlySpan.Length; i++) + { + if (char.IsControl(readOnlySpan[i])) + { + return true; + } + } + + return false; + } + } + } +} diff --git a/src/Mvc/Mvc.Core/test/BaseFileContentResultTest.cs b/src/Shared/ResultsTests/FileContentResultTestBase.cs similarity index 60% rename from src/Mvc/Mvc.Core/test/BaseFileContentResultTest.cs rename to src/Shared/ResultsTests/FileContentResultTestBase.cs index cb397e816a81..dace01e7e073 100644 --- a/src/Mvc/Mvc.Core/test/BaseFileContentResultTest.cs +++ b/src/Shared/ResultsTests/FileContentResultTestBase.cs @@ -6,48 +6,51 @@ using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Net.Http.Headers; using Xunit; -namespace Microsoft.AspNetCore.Mvc +namespace Microsoft.AspNetCore.Internal { - public class BaseFileContentResultTest + public abstract class FileContentResultTestBase { - public static async Task WriteFileAsync_CopiesBuffer_ToOutputStream( - Func function) + protected abstract Task ExecuteAsync( + HttpContext httpContext, + byte[] buffer, + string contentType, + DateTimeOffset? lastModified = null, + EntityTagHeaderValue entityTag = null, + bool enableRangeProcessing = false); + + [Fact] + public async Task WriteFileAsync_CopiesBuffer_ToOutputStream() { // Arrange var buffer = new byte[] { 1, 2, 3, 4, 5 }; - var httpContext = GetHttpContext(); var outStream = new MemoryStream(); httpContext.Response.Body = outStream; - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); - - var result = new FileContentResult(buffer, "text/plain"); - // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, buffer, "text/plain"); // Assert Assert.Equal(buffer, outStream.ToArray()); } - public static async Task WriteFileAsync_PreconditionStateShouldProcess_WritesRangeRequested( + [Theory] + [InlineData(0, 4, "Hello", 5)] + [InlineData(6, 10, "World", 5)] + [InlineData(null, 5, "World", 5)] + [InlineData(6, null, "World", 5)] + public async Task WriteFileAsync_PreconditionStateShouldProcess_WritesRangeRequested( long? start, long? end, string expectedString, - long contentLength, - Func function) + long contentLength) { // Arrange var contentType = "text/plain"; @@ -55,13 +58,6 @@ public static async Task WriteFileAsync_PreconditionStateShouldProcess_WritesRan var entityTag = new EntityTagHeaderValue("\"Etag\""); var byteArray = Encoding.ASCII.GetBytes("Hello World"); - var result = new FileContentResult(byteArray, contentType) - { - LastModified = lastModified, - EntityTag = entityTag, - EnableRangeProcessing = true, - }; - var httpContext = GetHttpContext(); var requestHeaders = httpContext.Request.GetTypedHeaders(); requestHeaders.Range = new RangeHeaderValue(start, end); @@ -71,16 +67,14 @@ public static async Task WriteFileAsync_PreconditionStateShouldProcess_WritesRan }; httpContext.Request.Method = HttpMethods.Get; httpContext.Response.Body = new MemoryStream(); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, byteArray, contentType, lastModified, entityTag, enableRangeProcessing: true); // Assert start = start ?? 11 - end; end = start + contentLength - 1; - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; httpResponse.Body.Seek(0, SeekOrigin.Begin); var streamReader = new StreamReader(httpResponse.Body); var body = streamReader.ReadToEndAsync().Result; @@ -94,8 +88,8 @@ public static async Task WriteFileAsync_PreconditionStateShouldProcess_WritesRan Assert.Equal(expectedString, body); } - public static async Task WriteFileAsync_IfRangeHeaderValid_WritesRangeRequest( - Func function) + [Fact] + public async Task WriteFileAsync_IfRangeHeaderValid_WritesRangeRequest() { // Arrange var contentType = "text/plain"; @@ -103,13 +97,6 @@ public static async Task WriteFileAsync_IfRangeHeaderValid_WritesRangeRequest( - Func function) + [Fact] + public async Task WriteFileAsync_RangeProcessingNotEnabled_RangeRequestIgnored() { // Arrange var contentType = "text/plain"; @@ -160,12 +136,6 @@ public static async Task WriteFileAsync_RangeProcessingNotEnabled_RangeRequestIg var entityTag = new EntityTagHeaderValue("\"Etag\""); var byteArray = Encoding.ASCII.GetBytes("Hello World"); - var result = new FileContentResult(byteArray, contentType) - { - LastModified = lastModified, - EntityTag = entityTag - }; - var httpContext = GetHttpContext(); var requestHeaders = httpContext.Request.GetTypedHeaders(); requestHeaders.IfMatch = new[] @@ -176,14 +146,12 @@ public static async Task WriteFileAsync_RangeProcessingNotEnabled_RangeRequestIg requestHeaders.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"Etag\"")); httpContext.Request.Method = HttpMethods.Get; httpContext.Response.Body = new MemoryStream(); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, byteArray, contentType, lastModified, entityTag); // Assert - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; httpResponse.Body.Seek(0, SeekOrigin.Begin); var streamReader = new StreamReader(httpResponse.Body); var body = streamReader.ReadToEndAsync().Result; @@ -193,8 +161,8 @@ public static async Task WriteFileAsync_RangeProcessingNotEnabled_RangeRequestIg Assert.Equal("Hello World", body); } - public static async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestIgnored( - Func function) + [Fact] + public async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestIgnored() { // Arrange var contentType = "text/plain"; @@ -202,13 +170,6 @@ public static async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestIgnored var entityTag = new EntityTagHeaderValue("\"Etag\""); var byteArray = Encoding.ASCII.GetBytes("Hello World"); - var result = new FileContentResult(byteArray, contentType) - { - LastModified = lastModified, - EntityTag = entityTag, - EnableRangeProcessing = true, - }; - var httpContext = GetHttpContext(); var requestHeaders = httpContext.Request.GetTypedHeaders(); requestHeaders.IfMatch = new[] @@ -219,14 +180,12 @@ public static async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestIgnored requestHeaders.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"NotEtag\"")); httpContext.Request.Method = HttpMethods.Get; httpContext.Response.Body = new MemoryStream(); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, byteArray, contentType, lastModified, entityTag, enableRangeProcessing: true); // Assert - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; httpResponse.Body.Seek(0, SeekOrigin.Begin); var streamReader = new StreamReader(httpResponse.Body); var body = streamReader.ReadToEndAsync().Result; @@ -236,9 +195,11 @@ public static async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestIgnored Assert.Equal("Hello World", body); } - public static async Task WriteFileAsync_PreconditionStateUnspecified_RangeRequestIgnored( - string rangeString, - Func function) + [Theory] + [InlineData("0-5")] + [InlineData("bytes = ")] + [InlineData("bytes = 1-4, 5-11")] + public async Task WriteFileAsync_PreconditionStateUnspecified_RangeRequestIgnored(string rangeString) { // Arrange var contentType = "text/plain"; @@ -246,25 +207,16 @@ public static async Task WriteFileAsync_PreconditionStateUnspecified_RangeReques var entityTag = new EntityTagHeaderValue("\"Etag\""); var byteArray = Encoding.ASCII.GetBytes("Hello World"); - var result = new FileContentResult(byteArray, contentType) - { - LastModified = lastModified, - EntityTag = entityTag, - EnableRangeProcessing = true, - }; - var httpContext = GetHttpContext(); httpContext.Request.Headers.Range = rangeString; httpContext.Request.Method = HttpMethods.Get; httpContext.Response.Body = new MemoryStream(); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, byteArray, contentType, lastModified, entityTag, enableRangeProcessing: true); // Assert - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; httpResponse.Body.Seek(0, SeekOrigin.Begin); var streamReader = new StreamReader(httpResponse.Body); var body = streamReader.ReadToEndAsync().Result; @@ -275,9 +227,10 @@ public static async Task WriteFileAsync_PreconditionStateUnspecified_RangeReques Assert.Equal("Hello World", body); } - public static async Task WriteFileAsync_PreconditionStateUnspecified_RangeRequestedNotSatisfiable( - string rangeString, - Func function) + [Theory] + [InlineData("bytes = 12-13")] + [InlineData("bytes = -0")] + public async Task WriteFileAsync_PreconditionStateUnspecified_RangeRequestedNotSatisfiable(string rangeString) { // Arrange var contentType = "text/plain"; @@ -285,25 +238,16 @@ public static async Task WriteFileAsync_PreconditionStateUnspecified_RangeReques var entityTag = new EntityTagHeaderValue("\"Etag\""); var byteArray = Encoding.ASCII.GetBytes("Hello World"); - var result = new FileContentResult(byteArray, contentType) - { - LastModified = lastModified, - EntityTag = entityTag, - EnableRangeProcessing = true, - }; - var httpContext = GetHttpContext(); httpContext.Request.Headers.Range = rangeString; httpContext.Request.Method = HttpMethods.Get; httpContext.Response.Body = new MemoryStream(); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, byteArray, contentType, lastModified, entityTag, enableRangeProcessing: true); // Assert - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; httpResponse.Body.Seek(0, SeekOrigin.Begin); var streamReader = new StreamReader(httpResponse.Body); var body = streamReader.ReadToEndAsync().Result; @@ -317,8 +261,8 @@ public static async Task WriteFileAsync_PreconditionStateUnspecified_RangeReques Assert.Empty(body); } - public static async Task WriteFileAsync_PreconditionFailed_RangeRequestedIgnored( - Func function) + [Fact] + public async Task WriteFileAsync_PreconditionFailed_RangeRequestedIgnored() { // Arrange var contentType = "text/plain"; @@ -326,30 +270,21 @@ public static async Task WriteFileAsync_PreconditionFailed_RangeRequestedIgnored var entityTag = new EntityTagHeaderValue("\"Etag\""); var byteArray = Encoding.ASCII.GetBytes("Hello World"); - var result = new FileContentResult(byteArray, contentType) - { - LastModified = lastModified, - EntityTag = entityTag, - EnableRangeProcessing = true, - }; - var httpContext = GetHttpContext(); var requestHeaders = httpContext.Request.GetTypedHeaders(); requestHeaders.IfMatch = new[] { - new EntityTagHeaderValue("\"NotEtag\""), - }; + new EntityTagHeaderValue("\"NotEtag\""), + }; httpContext.Request.Headers.Range = "bytes = 0-6"; httpContext.Request.Method = HttpMethods.Get; httpContext.Response.Body = new MemoryStream(); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, byteArray, contentType, lastModified, entityTag, enableRangeProcessing: true); // Assert - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; httpResponse.Body.Seek(0, SeekOrigin.Begin); var streamReader = new StreamReader(httpResponse.Body); var body = streamReader.ReadToEndAsync().Result; @@ -360,22 +295,15 @@ public static async Task WriteFileAsync_PreconditionFailed_RangeRequestedIgnored Assert.Empty(body); } - public static async Task WriteFileAsync_NotModified_RangeRequestedIgnored( - Func function) + [Fact] + public async Task WriteFileAsync_NotModified_RangeRequestedIgnored() { - // Arrange + // Arrange var contentType = "text/plain"; var lastModified = new DateTimeOffset(); var entityTag = new EntityTagHeaderValue("\"Etag\""); var byteArray = Encoding.ASCII.GetBytes("Hello World"); - var result = new FileContentResult(byteArray, contentType) - { - LastModified = lastModified, - EntityTag = entityTag, - EnableRangeProcessing = true, - }; - var httpContext = GetHttpContext(); var requestHeaders = httpContext.Request.GetTypedHeaders(); requestHeaders.IfNoneMatch = new[] @@ -385,14 +313,12 @@ public static async Task WriteFileAsync_NotModified_RangeRequestedIgnored( - Func function) + [Fact] + public async Task ExecuteResultAsync_SetsSuppliedContentTypeAndEncoding() { // Arrange var expectedContentType = "text/foo; charset=us-ascii"; @@ -416,13 +342,8 @@ public static async Task ExecuteResultAsync_SetsSuppliedContentTypeAndEncoding, FileContentResultExecutor>(); services.AddSingleton(NullLoggerFactory.Instance); return services; diff --git a/src/Shared/ResultsTests/FileStreamResultTestBase.cs b/src/Shared/ResultsTests/FileStreamResultTestBase.cs new file mode 100644 index 000000000000..f69c5d217346 --- /dev/null +++ b/src/Shared/ResultsTests/FileStreamResultTestBase.cs @@ -0,0 +1,463 @@ +// Copyright (c) .NET Foundation. 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 System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Microsoft.AspNetCore.Internal +{ + public abstract class FileStreamResultTestBase + { + protected abstract Task ExecuteAsync( + HttpContext httpContext, + Stream stream, + string contentType, + DateTimeOffset? lastModified = null, + EntityTagHeaderValue entityTag = null, + bool enableRangeProcessing = false); + + [Theory] + [InlineData(0, 4, "Hello", 5)] + [InlineData(6, 10, "World", 5)] + [InlineData(null, 5, "World", 5)] + [InlineData(6, null, "World", 5)] + public async Task WriteFileAsync_PreconditionStateShouldProcess_WritesRangeRequested(long? start, long? end, string expectedString, long contentLength) + { + // Arrange + var contentType = "text/plain"; + var lastModified = new DateTimeOffset(); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + var byteArray = Encoding.ASCII.GetBytes("Hello World"); + var readStream = new MemoryStream(byteArray); + readStream.SetLength(11); + + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.Range = new RangeHeaderValue(start, end); + requestHeaders.IfMatch = new[] + { + new EntityTagHeaderValue("\"Etag\""), + }; + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + + // Act + await ExecuteAsync(httpContext, readStream, contentType, lastModified, entityTag, enableRangeProcessing: true); + + // Assert + start = start ?? 11 - end; + end = start + contentLength - 1; + var httpResponse = httpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + var contentRange = new ContentRangeHeaderValue(start.Value, end.Value, byteArray.Length); + Assert.Equal(lastModified.ToString("R"), httpResponse.Headers.LastModified); + Assert.Equal(entityTag.ToString(), httpResponse.Headers.ETag); + Assert.Equal(StatusCodes.Status206PartialContent, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers.AcceptRanges); + Assert.Equal(contentRange.ToString(), httpResponse.Headers.ContentRange); + Assert.Equal(contentLength, httpResponse.ContentLength); + Assert.Equal(expectedString, body); + Assert.False(readStream.CanSeek); + } + + [Fact] + public async Task WriteFileAsync_IfRangeHeaderValid_WritesRequestedRange() + { + // Arrange + var contentType = "text/plain"; + var lastModified = DateTimeOffset.MinValue; + var entityTag = new EntityTagHeaderValue("\"Etag\""); + var byteArray = Encoding.ASCII.GetBytes("Hello World"); + var readStream = new MemoryStream(byteArray); + readStream.SetLength(11); + + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfMatch = new[] + { + new EntityTagHeaderValue("\"Etag\""), + }; + requestHeaders.Range = new RangeHeaderValue(0, 4); + requestHeaders.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"Etag\"")); + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + + // Act + await ExecuteAsync(httpContext, readStream, contentType, lastModified, entityTag, enableRangeProcessing: true); + + // Assert + var httpResponse = httpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Equal(lastModified.ToString("R"), httpResponse.Headers.LastModified); + Assert.Equal(entityTag.ToString(), httpResponse.Headers.ETag); + var contentRange = new ContentRangeHeaderValue(0, 4, byteArray.Length); + Assert.Equal(StatusCodes.Status206PartialContent, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers.AcceptRanges); + Assert.Equal(contentRange.ToString(), httpResponse.Headers.ContentRange); + Assert.Equal(5, httpResponse.ContentLength); + Assert.Equal("Hello", body); + Assert.False(readStream.CanSeek); + } + + [Fact] + public async Task WriteFileAsync_RangeProcessingNotEnabled_RangeRequestedIgnored() + { + // Arrange + var contentType = "text/plain"; + var lastModified = DateTimeOffset.MinValue; + var entityTag = new EntityTagHeaderValue("\"Etag\""); + var byteArray = Encoding.ASCII.GetBytes("Hello World"); + var readStream = new MemoryStream(byteArray); + readStream.SetLength(11); + + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfMatch = new[] + { + new EntityTagHeaderValue("\"Etag\""), + }; + requestHeaders.Range = new RangeHeaderValue(0, 4); + requestHeaders.IfRange = new RangeConditionHeaderValue(DateTimeOffset.MinValue); + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + + // Act + await ExecuteAsync(httpContext, readStream, contentType, lastModified, entityTag); + + // Assert + var httpResponse = httpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Equal(StatusCodes.Status200OK, httpResponse.StatusCode); + Assert.Equal(lastModified.ToString("R"), httpResponse.Headers.LastModified); + Assert.Equal(entityTag.ToString(), httpResponse.Headers.ETag); + Assert.Equal("Hello World", body); + Assert.False(readStream.CanSeek); + } + + [Fact] + public async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestedIgnored() + { + // Arrange + var contentType = "text/plain"; + var lastModified = DateTimeOffset.MinValue.AddDays(1); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + var byteArray = Encoding.ASCII.GetBytes("Hello World"); + var readStream = new MemoryStream(byteArray); + readStream.SetLength(11); + + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfMatch = new[] + { + new EntityTagHeaderValue("\"Etag\""), + }; + requestHeaders.Range = new RangeHeaderValue(0, 4); + requestHeaders.IfRange = new RangeConditionHeaderValue(DateTimeOffset.MinValue); + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + + // Act + await ExecuteAsync(httpContext, readStream, contentType, lastModified, entityTag, enableRangeProcessing: true); + + // Assert + var httpResponse = httpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Equal(StatusCodes.Status200OK, httpResponse.StatusCode); + Assert.Equal(lastModified.ToString("R"), httpResponse.Headers.LastModified); + Assert.Equal(entityTag.ToString(), httpResponse.Headers.ETag); + Assert.Equal("Hello World", body); + Assert.False(readStream.CanSeek); + } + + [Theory] + [InlineData("0-5")] + [InlineData("bytes = ")] + [InlineData("bytes = 1-4, 5-11")] + public async Task WriteFileAsync_PreconditionStateUnspecified_RangeRequestIgnored(string rangeString) + { + // Arrange + var contentType = "text/plain"; + var lastModified = new DateTimeOffset(); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + var byteArray = Encoding.ASCII.GetBytes("Hello World"); + var readStream = new MemoryStream(byteArray); + + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + httpContext.Request.Headers.Range = rangeString; + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + + // Act + await ExecuteAsync(httpContext, readStream, contentType, lastModified, entityTag, enableRangeProcessing: true); + + // Assert + var httpResponse = httpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Empty(httpResponse.Headers.ContentRange); + Assert.Equal(StatusCodes.Status200OK, httpResponse.StatusCode); + Assert.Equal(lastModified.ToString("R"), httpResponse.Headers.LastModified); + Assert.Equal(entityTag.ToString(), httpResponse.Headers.ETag); + Assert.Equal("Hello World", body); + Assert.False(readStream.CanSeek); + } + + [Theory] + [InlineData("bytes = 12-13")] + [InlineData("bytes = -0")] + public async Task WriteFileAsync_PreconditionStateUnspecified_RangeRequestedNotSatisfiable(string rangeString) + { + // Arrange + var contentType = "text/plain"; + var lastModified = new DateTimeOffset(); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + var byteArray = Encoding.ASCII.GetBytes("Hello World"); + var readStream = new MemoryStream(byteArray); + + var httpContext = GetHttpContext(); + httpContext.Request.Headers.Range = rangeString; + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + + // Act + await ExecuteAsync(httpContext, readStream, contentType, lastModified, entityTag, enableRangeProcessing: true); + + // Assert + var httpResponse = httpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + var contentRange = new ContentRangeHeaderValue(byteArray.Length); + Assert.Equal(lastModified.ToString("R"), httpResponse.Headers.LastModified); + Assert.Equal(entityTag.ToString(), httpResponse.Headers.ETag); + Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers.AcceptRanges); + Assert.Equal(contentRange.ToString(), httpResponse.Headers.ContentRange); + Assert.Equal(0, httpResponse.ContentLength); + Assert.Empty(body); + Assert.False(readStream.CanSeek); + } + + [Fact] + public async Task WriteFileAsync_RangeRequested_PreconditionFailed() + { + // Arrange + var contentType = "text/plain"; + var lastModified = new DateTimeOffset(); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + var byteArray = Encoding.ASCII.GetBytes("Hello World"); + var readStream = new MemoryStream(byteArray); + + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfMatch = new[] + { + new EntityTagHeaderValue("\"NotEtag\""), + }; + httpContext.Request.Headers.Range = "bytes = 0-6"; + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + + // Act + await ExecuteAsync(httpContext, readStream, contentType, lastModified, entityTag, enableRangeProcessing: true); + + // Assert + var httpResponse = httpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Equal(StatusCodes.Status412PreconditionFailed, httpResponse.StatusCode); + Assert.Null(httpResponse.ContentLength); + Assert.Empty(httpResponse.Headers.ContentRange); + Assert.NotEmpty(httpResponse.Headers.LastModified); + Assert.Empty(body); + Assert.False(readStream.CanSeek); + } + + [Fact] + public async Task WriteFileAsync_NotModified_RangeRequestedIgnored() + { + // Arrange + var contentType = "text/plain"; + var lastModified = new DateTimeOffset(); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + var byteArray = Encoding.ASCII.GetBytes("Hello World"); + var readStream = new MemoryStream(byteArray); + + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfNoneMatch = new[] + { + new EntityTagHeaderValue("\"Etag\""), + }; + httpContext.Request.Headers.Range = "bytes = 0-6"; + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + + // Act + await ExecuteAsync(httpContext, readStream, contentType, lastModified, entityTag, enableRangeProcessing: true); + + // Assert + var httpResponse = httpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = await streamReader.ReadToEndAsync(); + Assert.Equal(StatusCodes.Status304NotModified, httpResponse.StatusCode); + Assert.Null(httpResponse.ContentLength); + Assert.Empty(httpResponse.Headers.ContentRange); + Assert.False(httpResponse.Headers.ContainsKey(HeaderNames.ContentType)); + Assert.NotEmpty(httpResponse.Headers.LastModified); + Assert.Empty(body); + Assert.False(readStream.CanSeek); + } + + [Theory] + [InlineData(0)] + [InlineData(null)] + public async Task WriteFileAsync_RangeRequested_FileLengthZeroOrNull(long? fileLength) + { + // Arrange + var contentType = "text/plain"; + var lastModified = new DateTimeOffset(); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + var byteArray = Encoding.ASCII.GetBytes(""); + var readStream = new MemoryStream(byteArray); + fileLength = fileLength ?? 0L; + readStream.SetLength(fileLength.Value); + + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.Range = new RangeHeaderValue(0, 5); + requestHeaders.IfMatch = new[] + { + new EntityTagHeaderValue("\"Etag\""), + }; + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + + // Act + await ExecuteAsync(httpContext, readStream, contentType, lastModified, entityTag, enableRangeProcessing: true); + + // Assert + var httpResponse = httpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Equal(lastModified.ToString("R"), httpResponse.Headers.LastModified); + Assert.Equal(entityTag.ToString(), httpResponse.Headers.ETag); + var contentRange = new ContentRangeHeaderValue(byteArray.Length); + Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers.AcceptRanges); + Assert.Equal(contentRange.ToString(), httpResponse.Headers.ContentRange); + Assert.Equal(0, httpResponse.ContentLength); + Assert.Empty(body); + Assert.False(readStream.CanSeek); + } + + [Fact] + public async Task WriteFileAsync_CopiesProvidedStream_ToOutputStream() + { + // Arrange + // Generate an array of bytes with a predictable pattern + // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, F, 10, 11, 12, 13 + var originalBytes = Enumerable.Range(0, 0x1234) + .Select(b => (byte)(b % 20)).ToArray(); + + var originalStream = new MemoryStream(originalBytes); + + var httpContext = GetHttpContext(); + var outStream = new MemoryStream(); + httpContext.Response.Body = outStream; + + // Act + await ExecuteAsync(httpContext, originalStream, "text/plian"); + + // Assert + var outBytes = outStream.ToArray(); + Assert.True(originalBytes.SequenceEqual(outBytes)); + Assert.False(originalStream.CanSeek); + } + + [Fact] + public async Task SetsSuppliedContentTypeAndEncoding() + { + // Arrange + var expectedContentType = "text/foo; charset=us-ascii"; + // Generate an array of bytes with a predictable pattern + // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, F, 10, 11, 12, 13 + var originalBytes = Enumerable.Range(0, 0x1234) + .Select(b => (byte)(b % 20)).ToArray(); + + var originalStream = new MemoryStream(originalBytes); + + var httpContext = GetHttpContext(); + var outStream = new MemoryStream(); + httpContext.Response.Body = outStream; + + // Act + await ExecuteAsync(httpContext, originalStream, expectedContentType); + + // Assert + var outBytes = outStream.ToArray(); + Assert.True(originalBytes.SequenceEqual(outBytes)); + Assert.Equal(expectedContentType, httpContext.Response.ContentType); + Assert.False(originalStream.CanSeek); + } + + [Fact] + public async Task HeadRequest_DoesNotWriteToBody_AndClosesReadStream() + { + // Arrange + var readStream = new MemoryStream(Encoding.UTF8.GetBytes("Hello, World!")); + + var httpContext = GetHttpContext(); + httpContext.Request.Method = "HEAD"; + var outStream = new MemoryStream(); + httpContext.Response.Body = outStream; + + // Act + await ExecuteAsync(httpContext, readStream, "text/plain"); + + // Assert + Assert.False(readStream.CanSeek); + Assert.Equal(200, httpContext.Response.StatusCode); + Assert.Equal(0, httpContext.Response.Body.Length); + } + + private static IServiceCollection CreateServices() + { + var services = new ServiceCollection(); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddSingleton(NullLoggerFactory.Instance); + return services; + } + + private static HttpContext GetHttpContext() + { + var services = CreateServices(); + + var httpContext = new DefaultHttpContext(); + httpContext.RequestServices = services.BuildServiceProvider(); + return httpContext; + } + } +} diff --git a/src/Mvc/Mvc.Core/test/BasePhysicalFileResultTest.cs b/src/Shared/ResultsTests/PhysicalFileResultTestBase.cs similarity index 58% rename from src/Mvc/Mvc.Core/test/BasePhysicalFileResultTest.cs rename to src/Shared/ResultsTests/PhysicalFileResultTestBase.cs index c4d8960e879e..faaa01480cfa 100644 --- a/src/Mvc/Mvc.Core/test/BasePhysicalFileResultTest.cs +++ b/src/Shared/ResultsTests/PhysicalFileResultTestBase.cs @@ -2,16 +2,12 @@ // 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.IO.Pipelines; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; -using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -19,21 +15,27 @@ using Moq; using Xunit; -namespace Microsoft.AspNetCore.Mvc +namespace Microsoft.AspNetCore.Internal { - public class BasePhysicalFileResultTest + public abstract class PhysicalFileResultTestBase { - public static async Task WriteFileAsync_WritesRangeRequested( - long? start, - long? end, - string expectedString, - long contentLength, - Func function) + protected abstract Task ExecuteAsync( + HttpContext httpContext, + string path, + string contentType, + DateTimeOffset? lastModified = null, + EntityTagHeaderValue entityTag = null, + bool enableRangeProcessing = false); + + [Theory] + [InlineData(0, 3, 4)] + [InlineData(8, 13, 6)] + [InlineData(null, 5, 5)] + [InlineData(8, null, 26)] + public async Task WriteFileAsync_WritesRangeRequested(long? start, long? end, long contentLength) { // Arrange var path = Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")); - var result = new TestPhysicalFileResult(path, "text/plain"); - result.EnableRangeProcessing = true; var sendFile = new TestSendFileFeature(); var httpContext = GetHttpContext(); httpContext.Features.Set(sendFile); @@ -41,16 +43,14 @@ public static async Task WriteFileAsync_WritesRangeRequested( requestHeaders.IfModifiedSince = DateTimeOffset.MinValue; requestHeaders.Range = new RangeHeaderValue(start, end); httpContext.Request.Method = HttpMethods.Get; - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, path, "text/plain", enableRangeProcessing: true); // Assert var startResult = start ?? 34 - end; var endResult = startResult + contentLength - 1; - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; var contentRange = new ContentRangeHeaderValue(startResult.Value, endResult.Value, 34); Assert.Equal(StatusCodes.Status206PartialContent, httpResponse.StatusCode); Assert.Equal("bytes", httpResponse.Headers.AcceptRanges); @@ -62,14 +62,12 @@ public static async Task WriteFileAsync_WritesRangeRequested( Assert.Equal((long?)contentLength, sendFile.Length); } - public static async Task WriteFileAsync_IfRangeHeaderValid_WritesRequestedRange( - Func function) + [Fact] + public async Task WriteFileAsync_IfRangeHeaderValid_WritesRequestedRange() { // Arrange var path = Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")); - var result = new TestPhysicalFileResult(path, "text/plain"); - var entityTag = result.EntityTag = new EntityTagHeaderValue("\"Etag\""); - result.EnableRangeProcessing = true; + var entityTag = new EntityTagHeaderValue("\"Etag\""); var sendFile = new TestSendFileFeature(); var httpContext = GetHttpContext(); httpContext.Features.Set(sendFile); @@ -78,14 +76,12 @@ public static async Task WriteFileAsync_IfRangeHeaderValid_WritesRequestedRange< requestHeaders.Range = new RangeHeaderValue(0, 3); requestHeaders.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"Etag\"")); httpContext.Request.Method = HttpMethods.Get; - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, path, "text/plain", entityTag: entityTag, enableRangeProcessing: true); // Assert - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; Assert.Equal(StatusCodes.Status206PartialContent, httpResponse.StatusCode); Assert.Equal("bytes", httpResponse.Headers.AcceptRanges); var contentRange = new ContentRangeHeaderValue(0, 3, 34); @@ -98,13 +94,12 @@ public static async Task WriteFileAsync_IfRangeHeaderValid_WritesRequestedRange< Assert.Equal(4, sendFile.Length); } - public static async Task WriteFileAsync_RangeProcessingNotEnabled_RangeRequestedIgnored( - Func function) + [Fact] + public async Task WriteFileAsync_RangeProcessingNotEnabled_RangeRequestedIgnored() { // Arrange var path = Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")); - var result = new TestPhysicalFileResult(path, "text/plain"); - var entityTag = result.EntityTag = new EntityTagHeaderValue("\"Etag\""); + var entityTag = new EntityTagHeaderValue("\"Etag\""); var sendFile = new TestSendFileFeature(); var httpContext = GetHttpContext(); httpContext.Features.Set(sendFile); @@ -113,14 +108,12 @@ public static async Task WriteFileAsync_RangeProcessingNotEnabled_RangeRequested requestHeaders.Range = new RangeHeaderValue(0, 3); requestHeaders.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"Etag\"")); httpContext.Request.Method = HttpMethods.Get; - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, path, "text/plain", entityTag: entityTag); // Assert - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; Assert.Equal(StatusCodes.Status200OK, httpResponse.StatusCode); Assert.NotEmpty(httpResponse.Headers.LastModified); Assert.Equal(Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")), sendFile.Name); @@ -128,14 +121,12 @@ public static async Task WriteFileAsync_RangeProcessingNotEnabled_RangeRequested Assert.Null(sendFile.Length); } - public static async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestedIgnored( - Func function) + [Fact] + public async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestedIgnored() { // Arrange var path = Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")); - var result = new TestPhysicalFileResult(path, "text/plain"); - result.EnableRangeProcessing = true; - var entityTag = result.EntityTag = new EntityTagHeaderValue("\"Etag\""); + var entityTag = new EntityTagHeaderValue("\"Etag\""); var sendFile = new TestSendFileFeature(); var httpContext = GetHttpContext(); httpContext.Features.Set(sendFile); @@ -144,14 +135,12 @@ public static async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestedIgnor requestHeaders.Range = new RangeHeaderValue(0, 3); requestHeaders.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"NotEtag\"")); httpContext.Request.Method = HttpMethods.Get; - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, path, "text/plain", entityTag: entityTag, enableRangeProcessing: true); // Assert - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; Assert.Equal(StatusCodes.Status200OK, httpResponse.StatusCode); Assert.NotEmpty(httpResponse.Headers.LastModified); Assert.Equal(Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")), sendFile.Name); @@ -159,13 +148,14 @@ public static async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestedIgnor Assert.Null(sendFile.Length); } - public static async Task WriteFileAsync_RangeHeaderMalformed_RangeRequestIgnored( - string rangeString, - Func function) + [Theory] + [InlineData("0-5")] + [InlineData("bytes = ")] + [InlineData("bytes = 1-4, 5-11")] + public async Task WriteFileAsync_RangeHeaderMalformed_RangeRequestIgnored(string rangeString) { // Arrange var path = Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")); - var result = new TestPhysicalFileResult(path, "text/plain"); var sendFile = new TestSendFileFeature(); var httpContext = GetHttpContext(); httpContext.Features.Set(sendFile); @@ -173,14 +163,12 @@ public static async Task WriteFileAsync_RangeHeaderMalformed_RangeRequestIgnored requestHeaders.IfModifiedSince = DateTimeOffset.MinValue; httpContext.Request.Headers.Range = rangeString; httpContext.Request.Method = HttpMethods.Get; - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, path, "text/plain"); // Assert - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; Assert.Equal(StatusCodes.Status200OK, httpResponse.StatusCode); Assert.Empty(httpResponse.Headers.ContentRange); Assert.NotEmpty(httpResponse.Headers.LastModified); @@ -189,28 +177,25 @@ public static async Task WriteFileAsync_RangeHeaderMalformed_RangeRequestIgnored Assert.Null(sendFile.Length); } - public static async Task WriteFileAsync_RangeRequestedNotSatisfiable( - string rangeString, - Func function) + [Theory] + [InlineData("bytes = 35-36")] + [InlineData("bytes = -0")] + public async Task WriteFileAsync_RangeRequestedNotSatisfiable(string rangeString) { // Arrange var path = Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")); - var result = new TestPhysicalFileResult(path, "text/plain"); - result.EnableRangeProcessing = true; var httpContext = GetHttpContext(); var requestHeaders = httpContext.Request.GetTypedHeaders(); requestHeaders.IfModifiedSince = DateTimeOffset.MinValue; httpContext.Request.Headers.Range = rangeString; httpContext.Request.Method = HttpMethods.Get; httpContext.Response.Body = new MemoryStream(); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, path, "text/plain", enableRangeProcessing: true); // Assert - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; httpResponse.Body.Seek(0, SeekOrigin.Begin); var streamReader = new StreamReader(httpResponse.Body); var body = streamReader.ReadToEndAsync().Result; @@ -223,27 +208,23 @@ public static async Task WriteFileAsync_RangeRequestedNotSatisfiable( Assert.Empty(body); } - public static async Task WriteFileAsync_RangeRequested_PreconditionFailed( - Func function) + [Fact] + public async Task WriteFileAsync_RangeRequested_PreconditionFailed() { // Arrange var path = Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")); - var result = new TestPhysicalFileResult(path, "text/plain"); - result.EnableRangeProcessing = true; var httpContext = GetHttpContext(); var requestHeaders = httpContext.Request.GetTypedHeaders(); requestHeaders.IfUnmodifiedSince = DateTimeOffset.MinValue; httpContext.Request.Headers.Range = "bytes = 0-6"; httpContext.Request.Method = HttpMethods.Get; httpContext.Response.Body = new MemoryStream(); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, path, "text/plain", enableRangeProcessing: true); // Assert - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; httpResponse.Body.Seek(0, SeekOrigin.Begin); var streamReader = new StreamReader(httpResponse.Body); var body = streamReader.ReadToEndAsync().Result; @@ -254,27 +235,23 @@ public static async Task WriteFileAsync_RangeRequested_PreconditionFailed( - Func function) + [Fact] + public async Task WriteFileAsync_RangeRequested_NotModified() { // Arrange var path = Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")); - var result = new TestPhysicalFileResult(path, "text/plain"); - result.EnableRangeProcessing = true; var httpContext = GetHttpContext(); var requestHeaders = httpContext.Request.GetTypedHeaders(); requestHeaders.IfModifiedSince = DateTimeOffset.MinValue.AddDays(1); httpContext.Request.Headers.Range = "bytes = 0-6"; httpContext.Request.Method = HttpMethods.Get; httpContext.Response.Body = new MemoryStream(); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, path, "text/plain", enableRangeProcessing: true); // Assert - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; httpResponse.Body.Seek(0, SeekOrigin.Begin); var streamReader = new StreamReader(httpResponse.Body); var body = streamReader.ReadToEndAsync().Result; @@ -286,12 +263,11 @@ public static async Task WriteFileAsync_RangeRequested_NotModified( Assert.Empty(body); } - public static async Task ExecuteResultAsync_CallsSendFileAsync_IfIHttpSendFilePresent( - Func function) + [Fact] + public async Task ExecuteResultAsync_CallsSendFileAsync_IfIHttpSendFilePresent() { // Arrange var path = Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")); - var result = new TestPhysicalFileResult(path, "text/plain"); var sendFileMock = new Mock(); sendFileMock .Setup(s => s.SendFileAsync(path, 0, null, CancellationToken.None)) @@ -299,44 +275,38 @@ public static async Task ExecuteResultAsync_CallsSendFileAsync_IfIHttpSendFilePr var httpContext = GetHttpContext(); httpContext.Features.Set(sendFileMock.Object); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, path, "text/plain"); // Assert sendFileMock.Verify(); } - public static async Task ExecuteResultAsync_CallsSendFileAsyncWithRequestedRange_IfIHttpSendFilePresent( - long? start, - long? end, - long contentLength, - Func function) + [Theory] + [InlineData(0, 3, 4)] + [InlineData(8, 13, 6)] + [InlineData(null, 3, 3)] + [InlineData(8, null, 26)] + public async Task ExecuteResultAsync_CallsSendFileAsyncWithRequestedRange_IfIHttpSendFilePresent(long? start, long? end, long contentLength) { // Arrange var path = Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")); - var result = new TestPhysicalFileResult(path, "text/plain"); - result.EnableRangeProcessing = true; var sendFile = new TestSendFileFeature(); var httpContext = GetHttpContext(); httpContext.Features.Set(sendFile); - var context = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); var requestHeaders = httpContext.Request.GetTypedHeaders(); requestHeaders.Range = new RangeHeaderValue(start, end); requestHeaders.IfUnmodifiedSince = DateTimeOffset.MinValue.AddDays(1); httpContext.Request.Method = HttpMethods.Get; - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object functionContext = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)functionContext); + await ExecuteAsync(httpContext, path, "text/plain", enableRangeProcessing: true); // Assert - start = start ?? 34 - end; + start ??= 34 - end; end = start + contentLength - 1; - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; Assert.Equal(Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")), sendFile.Name); Assert.Equal(start, sendFile.Offset); Assert.Equal(contentLength, sendFile.Length); @@ -349,21 +319,18 @@ public static async Task ExecuteResultAsync_CallsSendFileAsyncWithRequestedRange Assert.Equal(contentLength, httpResponse.ContentLength); } - public static async Task ExecuteResultAsync_SetsSuppliedContentTypeAndEncoding( - Func function) + [Fact] + public async Task ExecuteResultAsync_SetsSuppliedContentTypeAndEncoding() { // Arrange var expectedContentType = "text/foo; charset=us-ascii"; var path = Path.GetFullPath(Path.Combine(".", "TestFiles", "FilePathResultTestFile_ASCII.txt")); - var result = new TestPhysicalFileResult(path, expectedContentType); var sendFile = new TestSendFileFeature(); var httpContext = GetHttpContext(); httpContext.Features.Set(sendFile); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, path, expectedContentType); // Assert Assert.Equal(expectedContentType, httpContext.Response.ContentType); @@ -373,22 +340,18 @@ public static async Task ExecuteResultAsync_SetsSuppliedContentTypeAndEncoding( - Func function) + [Fact] + public async Task ExecuteResultAsync_WorksWithAbsolutePaths() { // Arrange var path = Path.GetFullPath(Path.Combine(".", "TestFiles", "FilePathResultTestFile.txt")); - var result = new TestPhysicalFileResult(path, "text/plain"); var sendFile = new TestSendFileFeature(); var httpContext = GetHttpContext(); httpContext.Features.Set(sendFile); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); - // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, path, "text/plain"); // Assert Assert.Equal(Path.GetFullPath(Path.Combine(".", "TestFiles", "FilePathResultTestFile.txt")), sendFile.Name); @@ -397,90 +360,63 @@ public static async Task ExecuteResultAsync_WorksWithAbsolutePaths( Assert.Equal(CancellationToken.None, sendFile.Token); } - public static async Task ExecuteAsync_ThrowsNotSupported_ForNonRootedPaths( - string path, - Func function) + [Theory] + [InlineData("FilePathResultTestFile.txt")] + [InlineData("./FilePathResultTestFile.txt")] + [InlineData(".\\FilePathResultTestFile.txt")] + [InlineData("~/FilePathResultTestFile.txt")] + [InlineData("..\\TestFiles/FilePathResultTestFile.txt")] + [InlineData("..\\TestFiles\\FilePathResultTestFile.txt")] + [InlineData("..\\TestFiles/SubFolder/SubFolderTestFile.txt")] + [InlineData("..\\TestFiles\\SubFolder\\SubFolderTestFile.txt")] + [InlineData("..\\TestFiles/SubFolder\\SubFolderTestFile.txt")] + [InlineData("..\\TestFiles\\SubFolder/SubFolderTestFile.txt")] + [InlineData("~/SubFolder/SubFolderTestFile.txt")] + [InlineData("~/SubFolder\\SubFolderTestFile.txt")] + public async Task ExecuteAsync_ThrowsNotSupported_ForNonRootedPaths(string path) { // Arrange - var result = new TestPhysicalFileResult(path, "text/plain"); - var actionContext = new ActionContext(GetHttpContext(), new RouteData(), new ActionDescriptor()); var expectedMessage = $"Path '{path}' was not rooted."; + var httpContext = GetHttpContext(); // Act - object context = typeof(TContext) == typeof(HttpContext) ? actionContext.HttpContext : actionContext; var ex = await Assert.ThrowsAsync( - () => function(result, (TContext)context)); + () => ExecuteAsync(httpContext, path, "text/plain")); // Assert Assert.Equal(expectedMessage, ex.Message); } - public static void ExecuteAsync_ThrowsDirectoryNotFound_IfItCanNotFindTheDirectory_ForRootPaths( - string path, - Func function) + [Theory] + [InlineData("/SubFolder/SubFolderTestFile.txt")] + [InlineData("\\SubFolder\\SubFolderTestFile.txt")] + [InlineData("/SubFolder\\SubFolderTestFile.txt")] + [InlineData("\\SubFolder/SubFolderTestFile.txt")] + [InlineData("./SubFolder/SubFolderTestFile.txt")] + [InlineData(".\\SubFolder\\SubFolderTestFile.txt")] + [InlineData("./SubFolder\\SubFolderTestFile.txt")] + [InlineData(".\\SubFolder/SubFolderTestFile.txt")] + public void ExecuteAsync_ThrowsDirectoryNotFound_IfItCanNotFindTheDirectory_ForRootPaths(string path) { // Arrange - var result = new TestPhysicalFileResult(path, "text/plain"); - var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); + var httpContext = GetHttpContext(); // Act & Assert - object context = typeof(TContext) == typeof(HttpContext) ? actionContext.HttpContext : actionContext; Assert.ThrowsAsync( - () => function(result, (TContext)context)); + () => ExecuteAsync(httpContext, path, "text/plain")); } - public static void ExecuteAsync_ThrowsFileNotFound_WhenFileDoesNotExist_ForRootPaths( - string path, - Func function) + [Theory] + [InlineData("/FilePathResultTestFile.txt")] + [InlineData("\\FilePathResultTestFile.txt")] + public void ExecuteAsync_ThrowsFileNotFound_WhenFileDoesNotExist_ForRootPaths(string path) { // Arrange - var result = new TestPhysicalFileResult(path, "text/plain"); - var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); + var httpContext = GetHttpContext(); // Act & Assert - object context = typeof(TContext) == typeof(HttpContext) ? actionContext.HttpContext : actionContext; Assert.ThrowsAsync( - () => function(result, (TContext)context)); - } - - private class TestPhysicalFileResult : PhysicalFileResult, IResult - { - public TestPhysicalFileResult(string filePath, string contentType) - : base(filePath, contentType) - { - } - - public override Task ExecuteResultAsync(ActionContext context) - { - var executor = context.HttpContext.RequestServices.GetRequiredService(); - return executor.ExecuteAsync(context, this); - } - - Task IResult.ExecuteAsync(HttpContext httpContext) - { - var lastModified = DateTimeOffset.MinValue.AddDays(1); - var fileLastModified = new DateTimeOffset(lastModified.Year, lastModified.Month, lastModified.Day, lastModified.Hour, lastModified.Minute, lastModified.Second, TimeSpan.FromSeconds(0)); - return ExecuteAsyncInternal(httpContext, this, fileLastModified, 34); - } - } - - private class TestPhysicalFileResultExecutor : PhysicalFileResultExecutor - { - public TestPhysicalFileResultExecutor(ILoggerFactory loggerFactory) - : base(loggerFactory) - { - } - - protected override FileMetadata GetFileInfo(string path) - { - var lastModified = DateTimeOffset.MinValue.AddDays(1); - return new FileMetadata - { - Exists = true, - Length = 34, - LastModified = new DateTimeOffset(lastModified.Year, lastModified.Month, lastModified.Day, lastModified.Hour, lastModified.Minute, lastModified.Second, TimeSpan.FromSeconds(0)) - }; - } + () => ExecuteAsync(httpContext, path, "text/plain")); } private class TestSendFileFeature : IHttpResponseBodyFeature @@ -522,8 +458,8 @@ public Task StartAsync(CancellationToken cancellation = default) private static IServiceCollection CreateServices() { var services = new ServiceCollection(); - services.AddSingleton(); services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); return services; } diff --git a/src/Mvc/Mvc.Core/test/BaseRedirectResultTest.cs b/src/Shared/ResultsTests/RedirectResultTestBase.cs similarity index 50% rename from src/Mvc/Mvc.Core/test/BaseRedirectResultTest.cs rename to src/Shared/ResultsTests/RedirectResultTestBase.cs index 6097a09263ec..7c459e9d9e74 100644 --- a/src/Mvc/Mvc.Core/test/BaseRedirectResultTest.cs +++ b/src/Shared/ResultsTests/RedirectResultTestBase.cs @@ -4,33 +4,30 @@ using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.AspNetCore.Mvc.Routing; -using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Moq; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; -namespace Microsoft.AspNetCore.Mvc +namespace Microsoft.AspNetCore.Internal { - public class BaseRedirectResultTest + public abstract class RedirectResultTestBase { - public static async Task Execute_ReturnsContentPath_WhenItDoesNotStartWithTilde( + protected abstract Task ExecuteAsync(HttpContext httpContext, string contentPath); + + [Theory] + [InlineData("", "/Home/About", "/Home/About")] + [InlineData("/myapproot", "/test", "/test")] + public async Task Execute_ReturnsContentPath_WhenItDoesNotStartWithTilde( string appRoot, string contentPath, - string expectedPath, - Func function) + string expectedPath) { // Arrange var httpContext = GetHttpContext(appRoot); - var actionContext = GetActionContext(httpContext); - var result = new RedirectResult(contentPath); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext) context); + await ExecuteAsync(httpContext, contentPath); // Assert // Verifying if Redirect was called with the specific Url and parameter flag. @@ -38,20 +35,22 @@ public static async Task Execute_ReturnsContentPath_WhenItDoesNotStartWithTilde< Assert.Equal(StatusCodes.Status302Found, httpContext.Response.StatusCode); } - public static async Task Execute_ReturnsAppRelativePath_WhenItStartsWithTilde( + [Theory] + [InlineData(null, "~/Home/About", "/Home/About")] + [InlineData("/", "~/Home/About", "/Home/About")] + [InlineData("/", "~/", "/")] + [InlineData("", "~/Home/About", "/Home/About")] + [InlineData("/myapproot", "~/", "/myapproot/")] + public async Task Execute_ReturnsAppRelativePath_WhenItStartsWithTilde( string appRoot, string contentPath, - string expectedPath, - Func function) + string expectedPath) { // Arrange var httpContext = GetHttpContext(appRoot); - var actionContext = GetActionContext(httpContext); - var result = new RedirectResult(contentPath); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, contentPath); // Assert // Verifying if Redirect was called with the specific Url and parameter flag. @@ -59,23 +58,11 @@ public static async Task Execute_ReturnsAppRelativePath_WhenItStartsWithTilde()); - - return new ActionContext( - httpContext, - routeData, - new ActionDescriptor()); - } - private static IServiceProvider GetServiceProvider() { var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton, RedirectResultExecutor>(); - serviceCollection.AddSingleton(); - serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(typeof(ILogger<>), typeof(NullLogger<>)); return serviceCollection.BuildServiceProvider(); } diff --git a/src/Mvc/Mvc.Core/test/BaseVirtualFileResultTest.cs b/src/Shared/ResultsTests/VirtualFileResultTestBase.cs similarity index 50% rename from src/Mvc/Mvc.Core/test/BaseVirtualFileResultTest.cs rename to src/Shared/ResultsTests/VirtualFileResultTestBase.cs index 727d18eadb8f..e224ff651a81 100644 --- a/src/Mvc/Mvc.Core/test/BaseVirtualFileResultTest.cs +++ b/src/Shared/ResultsTests/VirtualFileResultTestBase.cs @@ -10,9 +10,6 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; -using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; @@ -21,49 +18,52 @@ using Moq; using Xunit; -namespace Microsoft.AspNetCore.Mvc +namespace Microsoft.AspNetCore.Internal { - public class BaseVirtualFileResultTest + public abstract class VirtualFileResultTestBase { - public static async Task WriteFileAsync_WritesRangeRequested( + protected abstract Task ExecuteAsync( + HttpContext httpContext, + string path, + string contentType, + DateTimeOffset? lastModified = null, + EntityTagHeaderValue entityTag = null, + bool enableRangeProcessing = false); + + [Theory] + [InlineData(0, 3, 4)] + [InlineData(8, 13, 6)] + [InlineData(null, 4, 4)] + [InlineData(8, null, 25)] + public async Task WriteFileAsync_WritesRangeRequested( long? start, long? end, - string expectedString, - long contentLength, - Func function) + long contentLength) { // Arrange var path = Path.GetFullPath("helllo.txt"); var contentType = "text/plain; charset=us-ascii; p1=p1-value"; - var result = new TestVirtualFileResult(path, contentType); - result.EnableRangeProcessing = true; var appEnvironment = new Mock(); appEnvironment.Setup(app => app.WebRootFileProvider) .Returns(GetFileProvider(path)); var sendFileFeature = new TestSendFileFeature(); - var httpContext = GetHttpContext(); + var httpContext = GetHttpContext(GetFileProvider(path)); httpContext.Features.Set(sendFileFeature); - httpContext.RequestServices = new ServiceCollection() - .AddSingleton(appEnvironment.Object) - .AddTransient, TestVirtualFileResultExecutor>() - .AddTransient() - .BuildServiceProvider(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); requestHeaders.Range = new RangeHeaderValue(start, end); requestHeaders.IfUnmodifiedSince = DateTimeOffset.MinValue.AddDays(1); httpContext.Request.Method = HttpMethods.Get; - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, path, contentType, enableRangeProcessing: true); // Assert var startResult = start ?? 33 - end; var endResult = startResult + contentLength - 1; - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; var contentRange = new ContentRangeHeaderValue(startResult.Value, endResult.Value, 33); Assert.Equal(StatusCodes.Status206PartialContent, httpResponse.StatusCode); Assert.Equal("bytes", httpResponse.Headers.AcceptRanges); @@ -75,41 +75,32 @@ public static async Task WriteFileAsync_WritesRangeRequested( Assert.Equal((long?)contentLength, sendFileFeature.Length); } - public static async Task WriteFileAsync_IfRangeHeaderValid_WritesRequestedRange( - Func function) + [Fact] + public async Task WriteFileAsync_IfRangeHeaderValid_WritesRequestedRange() { // Arrange var path = Path.GetFullPath("helllo.txt"); var contentType = "text/plain; charset=us-ascii; p1=p1-value"; - var result = new TestVirtualFileResult(path, contentType); - result.EnableRangeProcessing = true; var appEnvironment = new Mock(); appEnvironment.Setup(app => app.WebRootFileProvider) .Returns(GetFileProvider(path)); var sendFileFeature = new TestSendFileFeature(); - var httpContext = GetHttpContext(); + var httpContext = GetHttpContext(GetFileProvider(path)); httpContext.Features.Set(sendFileFeature); - httpContext.RequestServices = new ServiceCollection() - .AddSingleton(appEnvironment.Object) - .AddTransient, TestVirtualFileResultExecutor>() - .AddTransient() - .BuildServiceProvider(); - var entityTag = result.EntityTag = new EntityTagHeaderValue("\"Etag\""); + var entityTag = new EntityTagHeaderValue("\"Etag\""); var requestHeaders = httpContext.Request.GetTypedHeaders(); requestHeaders.IfModifiedSince = DateTimeOffset.MinValue; requestHeaders.Range = new RangeHeaderValue(0, 3); requestHeaders.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"Etag\"")); httpContext.Request.Method = HttpMethods.Get; - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, path, contentType, entityTag: entityTag, enableRangeProcessing: true); // Assert - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; Assert.Equal(StatusCodes.Status206PartialContent, httpResponse.StatusCode); Assert.Equal("bytes", httpResponse.Headers.AcceptRanges); var contentRange = new ContentRangeHeaderValue(0, 3, 33); @@ -121,40 +112,32 @@ public static async Task WriteFileAsync_IfRangeHeaderValid_WritesRequestedRange< Assert.Equal(4, sendFileFeature.Length); } - public static async Task WriteFileAsync_RangeProcessingNotEnabled_RangeRequestedIgnored( - Func function) + [Fact] + public async Task WriteFileAsync_RangeProcessingNotEnabled_RangeRequestedIgnored() { // Arrange var path = Path.GetFullPath("helllo.txt"); var contentType = "text/plain; charset=us-ascii; p1=p1-value"; - var result = new TestVirtualFileResult(path, contentType); var appEnvironment = new Mock(); appEnvironment.Setup(app => app.WebRootFileProvider) .Returns(GetFileProvider(path)); var sendFileFeature = new TestSendFileFeature(); - var httpContext = GetHttpContext(); + var httpContext = GetHttpContext(GetFileProvider(path)); httpContext.Features.Set(sendFileFeature); - httpContext.RequestServices = new ServiceCollection() - .AddSingleton(appEnvironment.Object) - .AddTransient, TestVirtualFileResultExecutor>() - .AddTransient() - .BuildServiceProvider(); - var entityTag = result.EntityTag = new EntityTagHeaderValue("\"Etag\""); + var entityTag = new EntityTagHeaderValue("\"Etag\""); var requestHeaders = httpContext.Request.GetTypedHeaders(); requestHeaders.IfModifiedSince = DateTimeOffset.MinValue; requestHeaders.Range = new RangeHeaderValue(0, 3); requestHeaders.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"Etag\"")); httpContext.Request.Method = HttpMethods.Get; - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, path, contentType, entityTag: entityTag); // Assert - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; Assert.Equal(StatusCodes.Status200OK, httpResponse.StatusCode); Assert.Equal(entityTag.ToString(), httpResponse.Headers.ETag); Assert.Equal(path, sendFileFeature.Name); @@ -162,41 +145,32 @@ public static async Task WriteFileAsync_RangeProcessingNotEnabled_RangeRequested Assert.Null(sendFileFeature.Length); } - public static async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestedIgnored( - Func function) + [Fact] + public async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestedIgnored() { // Arrange var path = Path.GetFullPath("helllo.txt"); var contentType = "text/plain; charset=us-ascii; p1=p1-value"; - var result = new TestVirtualFileResult(path, contentType); - result.EnableRangeProcessing = true; var appEnvironment = new Mock(); appEnvironment.Setup(app => app.WebRootFileProvider) .Returns(GetFileProvider(path)); var sendFileFeature = new TestSendFileFeature(); - var httpContext = GetHttpContext(); + var httpContext = GetHttpContext(GetFileProvider(path)); httpContext.Features.Set(sendFileFeature); - httpContext.RequestServices = new ServiceCollection() - .AddSingleton(appEnvironment.Object) - .AddTransient, TestVirtualFileResultExecutor>() - .AddTransient() - .BuildServiceProvider(); - var entityTag = result.EntityTag = new EntityTagHeaderValue("\"Etag\""); + var entityTag = new EntityTagHeaderValue("\"Etag\""); var requestHeaders = httpContext.Request.GetTypedHeaders(); requestHeaders.IfModifiedSince = DateTimeOffset.MinValue; requestHeaders.Range = new RangeHeaderValue(0, 3); requestHeaders.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"NotEtag\"")); httpContext.Request.Method = HttpMethods.Get; - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, path, contentType, entityTag: entityTag, enableRangeProcessing: true); // Assert - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; Assert.Equal(StatusCodes.Status200OK, httpResponse.StatusCode); Assert.Equal(entityTag.ToString(), httpResponse.Headers.ETag); Assert.Equal(path, sendFileFeature.Name); @@ -204,40 +178,33 @@ public static async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestedIgnor Assert.Null(sendFileFeature.Length); } - public static async Task WriteFileAsync_RangeHeaderMalformed_RangeRequestIgnored( - string rangeString, - Func function) + [Theory] + [InlineData("0-5")] + [InlineData("bytes = ")] + [InlineData("bytes = 1-4, 5-11")] + public async Task WriteFileAsync_RangeHeaderMalformed_RangeRequestIgnored(string rangeString) { // Arrange var path = Path.GetFullPath("helllo.txt"); var contentType = "text/plain; charset=us-ascii; p1=p1-value"; - var result = new TestVirtualFileResult(path, contentType); - result.EnableRangeProcessing = true; var appEnvironment = new Mock(); appEnvironment.Setup(app => app.WebRootFileProvider) .Returns(GetFileProvider(path)); var sendFileFeature = new TestSendFileFeature(); - var httpContext = GetHttpContext(); + var httpContext = GetHttpContext(GetFileProvider(path)); httpContext.Features.Set(sendFileFeature); - httpContext.RequestServices = new ServiceCollection() - .AddSingleton(appEnvironment.Object) - .AddTransient, TestVirtualFileResultExecutor>() - .AddTransient() - .BuildServiceProvider(); var requestHeaders = httpContext.Request.GetTypedHeaders(); httpContext.Request.Headers.Range = rangeString; requestHeaders.IfUnmodifiedSince = DateTimeOffset.MinValue.AddDays(1); httpContext.Request.Method = HttpMethods.Get; - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, path, contentType, enableRangeProcessing: true); // Assert - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; Assert.Equal(StatusCodes.Status200OK, httpResponse.StatusCode); Assert.Empty(httpResponse.Headers.ContentRange); Assert.NotEmpty(httpResponse.Headers.LastModified); @@ -246,40 +213,32 @@ public static async Task WriteFileAsync_RangeHeaderMalformed_RangeRequestIgnored Assert.Null(sendFileFeature.Length); } - public static async Task WriteFileAsync_RangeRequestedNotSatisfiable( - string rangeString, - Func function) + [Theory] + [InlineData("bytes = 35-36")] + [InlineData("bytes = -0")] + public async Task WriteFileAsync_RangeRequestedNotSatisfiable(string rangeString) { // Arrange var path = Path.GetFullPath("helllo.txt"); var contentType = "text/plain; charset=us-ascii; p1=p1-value"; - var result = new TestVirtualFileResult(path, contentType); - result.EnableRangeProcessing = true; var appEnvironment = new Mock(); appEnvironment.Setup(app => app.WebRootFileProvider) .Returns(GetFileProvider(path)); - var httpContext = GetHttpContext(); + var httpContext = GetHttpContext(GetFileProvider(path)); httpContext.Response.Body = new MemoryStream(); - httpContext.RequestServices = new ServiceCollection() - .AddSingleton(appEnvironment.Object) - .AddTransient, TestVirtualFileResultExecutor>() - .AddTransient() - .BuildServiceProvider(); var requestHeaders = httpContext.Request.GetTypedHeaders(); httpContext.Request.Headers.Range = rangeString; requestHeaders.IfUnmodifiedSince = DateTimeOffset.MinValue.AddDays(1); httpContext.Request.Method = HttpMethods.Get; httpContext.Response.Body = new MemoryStream(); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, path, contentType, enableRangeProcessing: true); // Assert - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; httpResponse.Body.Seek(0, SeekOrigin.Begin); var streamReader = new StreamReader(httpResponse.Body); var body = streamReader.ReadToEndAsync().Result; @@ -292,39 +251,30 @@ public static async Task WriteFileAsync_RangeRequestedNotSatisfiable( Assert.Empty(body); } - public static async Task WriteFileAsync_RangeRequested_PreconditionFailed( - Func function) + [Fact] + public async Task WriteFileAsync_RangeRequested_PreconditionFailed() { // Arrange var path = Path.GetFullPath("helllo.txt"); var contentType = "text/plain; charset=us-ascii; p1=p1-value"; - var result = new TestVirtualFileResult(path, contentType); - result.EnableRangeProcessing = true; var appEnvironment = new Mock(); appEnvironment.Setup(app => app.WebRootFileProvider) .Returns(GetFileProvider(path)); var sendFileFeature = new TestSendFileFeature(); - var httpContext = GetHttpContext(); + var httpContext = GetHttpContext(GetFileProvider(path)); httpContext.Features.Set(sendFileFeature); - httpContext.RequestServices = new ServiceCollection() - .AddSingleton(appEnvironment.Object) - .AddTransient, TestVirtualFileResultExecutor>() - .AddTransient() - .BuildServiceProvider(); var requestHeaders = httpContext.Request.GetTypedHeaders(); requestHeaders.IfUnmodifiedSince = DateTimeOffset.MinValue; httpContext.Request.Headers.Range = "bytes = 0-6"; httpContext.Request.Method = HttpMethods.Get; - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, path, contentType, enableRangeProcessing: true); // Assert - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; Assert.Equal(StatusCodes.Status412PreconditionFailed, httpResponse.StatusCode); Assert.Null(httpResponse.ContentLength); Assert.Empty(httpResponse.Headers.ContentRange); @@ -332,39 +282,31 @@ public static async Task WriteFileAsync_RangeRequested_PreconditionFailed( - Func function) + [Fact] + public async Task WriteFileAsync_RangeRequested_NotModified() { // Arrange var path = Path.GetFullPath("helllo.txt"); var contentType = "text/plain; charset=us-ascii; p1=p1-value"; - var result = new TestVirtualFileResult(path, contentType); - result.EnableRangeProcessing = true; var appEnvironment = new Mock(); appEnvironment.Setup(app => app.WebRootFileProvider) .Returns(GetFileProvider(path)); var sendFileFeature = new TestSendFileFeature(); - var httpContext = GetHttpContext(); + var httpContext = GetHttpContext(GetFileProvider(path)); httpContext.Features.Set(sendFileFeature); - httpContext.RequestServices = new ServiceCollection() - .AddSingleton(appEnvironment.Object) - .AddTransient, TestVirtualFileResultExecutor>() - .AddTransient() - .BuildServiceProvider(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); requestHeaders.IfModifiedSince = DateTimeOffset.MinValue.AddDays(1); httpContext.Request.Headers.Range = "bytes = 0-6"; httpContext.Request.Method = HttpMethods.Get; - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, path, contentType, enableRangeProcessing: true); // Assert - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; Assert.Equal(StatusCodes.Status304NotModified, httpResponse.StatusCode); Assert.Null(httpResponse.ContentLength); Assert.Empty(httpResponse.Headers.ContentRange); @@ -373,105 +315,35 @@ public static async Task WriteFileAsync_RangeRequested_NotModified( Assert.Null(sendFileFeature.Name); // Not called } - public static async Task ExecuteResultAsync_FallsBackToWebRootFileProvider_IfNoFileProviderIsPresent( - Func function) - { - // Arrange - var path = Path.Combine("TestFiles", "FilePathResultTestFile.txt"); - var result = new TestVirtualFileResult(path, "text/plain"); - - var appEnvironment = new Mock(); - appEnvironment.Setup(app => app.WebRootFileProvider) - .Returns(GetFileProvider(path)); - - var sendFileFeature = new TestSendFileFeature(); - var httpContext = GetHttpContext(); - httpContext.Features.Set(sendFileFeature); - httpContext.RequestServices = new ServiceCollection() - .AddSingleton(appEnvironment.Object) - .AddTransient, TestVirtualFileResultExecutor>() - .AddTransient() - .BuildServiceProvider(); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); - - // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); - - // Assert - Assert.Equal(path, sendFileFeature.Name); - Assert.Equal(0, sendFileFeature.Offset); - Assert.Null(sendFileFeature.Length); - } - - public static async Task ExecuteResultAsync_CallsSendFileAsync_IfIHttpSendFilePresent( - Func function) - { - // Arrange - var path = Path.Combine("TestFiles", "FilePathResultTestFile.txt"); - var result = new TestVirtualFileResult(path, "text/plain") - { - FileProvider = GetFileProvider(path), - }; - - var sendFileMock = new Mock(); - sendFileMock - .Setup(s => s.SendFileAsync(path, 0, null, CancellationToken.None)) - .Returns(Task.FromResult(0)); - - var httpContext = GetHttpContext(); - httpContext.Features.Set(sendFileMock.Object); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); - - // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); - - // Assert - sendFileMock.Verify(); - } - public static async Task ExecuteResultAsync_CallsSendFileAsyncWithRequestedRange_IfIHttpSendFilePresent( - long? start, - long? end, - string expectedString, - long contentLength, - Func function) + [Theory] + [InlineData(0, 3, 4)] + [InlineData(8, 13, 6)] + [InlineData(null, 3, 3)] + [InlineData(8, null, 25)] + public async Task ExecuteResultAsync_CallsSendFileAsyncWithRequestedRange_IfIHttpSendFilePresent(long? start, long? end, long contentLength) { // Arrange var path = Path.Combine("TestFiles", "FilePathResultTestFile.txt"); - var result = new TestVirtualFileResult(path, "text/plain") - { - FileProvider = GetFileProvider(path), - EnableRangeProcessing = true, - }; var sendFile = new TestSendFileFeature(); - var httpContext = GetHttpContext(); + var httpContext = GetHttpContext(GetFileProvider(path)); httpContext.Features.Set(sendFile); - var context = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); var appEnvironment = new Mock(); appEnvironment.Setup(app => app.WebRootFileProvider) .Returns(GetFileProvider(path)); - httpContext.RequestServices = new ServiceCollection() - .AddSingleton(appEnvironment.Object) - .AddTransient, TestVirtualFileResultExecutor>() - .AddTransient() - .BuildServiceProvider(); var requestHeaders = httpContext.Request.GetTypedHeaders(); requestHeaders.Range = new RangeHeaderValue(start, end); requestHeaders.IfUnmodifiedSince = DateTimeOffset.MinValue.AddDays(1); httpContext.Request.Method = HttpMethods.Get; - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object functionContext = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext) functionContext); + await ExecuteAsync(httpContext, path, "text/plain", enableRangeProcessing: true); // Assert start = start ?? 33 - end; end = start + contentLength - 1; - var httpResponse = actionContext.HttpContext.Response; + var httpResponse = httpContext.Response; Assert.Equal(Path.Combine("TestFiles", "FilePathResultTestFile.txt"), sendFile.Name); Assert.Equal(start, sendFile.Offset); Assert.Equal(contentLength, sendFile.Length); @@ -484,112 +356,91 @@ public static async Task ExecuteResultAsync_CallsSendFileAsyncWithRequestedRange Assert.Equal(contentLength, httpResponse.ContentLength); } - public static async Task ExecuteResultAsync_SetsSuppliedContentTypeAndEncoding( - Func function) + [Fact] + public async Task ExecuteResultAsync_SetsSuppliedContentTypeAndEncoding() { // Arrange var expectedContentType = "text/foo; charset=us-ascii"; - var result = new TestVirtualFileResult( - "FilePathResultTestFile_ASCII.txt", expectedContentType) - { - FileProvider = GetFileProvider("FilePathResultTestFile_ASCII.txt"), - }; var sendFileFeature = new TestSendFileFeature(); - var httpContext = GetHttpContext(); + var httpContext = GetHttpContext(GetFileProvider("FilePathResultTestFile_ASCII.txt")); httpContext.Features.Set(sendFileFeature); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, "FilePathResultTestFile_ASCII.txt", expectedContentType); // Assert Assert.Equal(expectedContentType, httpContext.Response.ContentType); Assert.Equal("FilePathResultTestFile_ASCII.txt", sendFileFeature.Name); } - public static async Task ExecuteResultAsync_ReturnsFileContentsForRelativePaths( - Func function) + [Fact] + public async Task ExecuteResultAsync_ReturnsFileContentsForRelativePaths() { // Arrange var path = Path.Combine("TestFiles", "FilePathResultTestFile.txt"); - var result = new TestVirtualFileResult(path, "text/plain") - { - FileProvider = GetFileProvider(path), - }; var sendFileFeature = new TestSendFileFeature(); - var httpContext = GetHttpContext(); + var httpContext = GetHttpContext(GetFileProvider(path)); httpContext.Features.Set(sendFileFeature); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, path, "text/plain"); // Assert Assert.Equal(path, sendFileFeature.Name); } - public static async Task ExecuteResultAsync_ReturnsFiles_ForDifferentPaths( - string path, - Func function) + [Theory] + [InlineData("FilePathResultTestFile.txt")] + [InlineData("TestFiles/FilePathResultTestFile.txt")] + [InlineData("TestFiles/../FilePathResultTestFile.txt")] + [InlineData("TestFiles\\FilePathResultTestFile.txt")] + [InlineData("TestFiles\\..\\FilePathResultTestFile.txt")] + [InlineData(@"\\..//?><|""&@#\c:\..\? /..txt")] + public async Task ExecuteResultAsync_ReturnsFiles_ForDifferentPaths(string path) { // Arrange - var result = new TestVirtualFileResult(path, "text/plain") - { - FileProvider = GetFileProvider(path), - }; - var sendFileFeature = new TestSendFileFeature(); - var httpContext = GetHttpContext(); + var webRootFileProvider = GetFileProvider(path); + var httpContext = GetHttpContext(webRootFileProvider); httpContext.Features.Set(sendFileFeature); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); - // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, path, "text/plain"); // Assert - Mock.Get(result.FileProvider).Verify(); + Mock.Get(webRootFileProvider).Verify(); Assert.Equal(path, sendFileFeature.Name); } - public static async Task ExecuteResultAsync_TrimsTilde_BeforeInvokingFileProvider( - string path, - Func function) + [Theory] + [InlineData("~/FilePathResultTestFile.txt")] + [InlineData("~/TestFiles/FilePathResultTestFile.txt")] + [InlineData("~/TestFiles/../FilePathResultTestFile.txt")] + [InlineData("~/TestFiles\\..\\FilePathResultTestFile.txt")] + [InlineData(@"~~~~\\..//?>~<|""&@#\c:\..\? /..txt~~~")] + public async Task ExecuteResultAsync_TrimsTilde_BeforeInvokingFileProvider(string path) { // Arrange var expectedPath = path.Substring(1); - var result = new TestVirtualFileResult(path, "text/plain") - { - FileProvider = GetFileProvider(expectedPath), - }; - var sendFileFeature = new TestSendFileFeature(); - var httpContext = GetHttpContext(); + var webRootFileProvider = GetFileProvider(expectedPath); + var httpContext = GetHttpContext(webRootFileProvider); httpContext.Features.Set(sendFileFeature); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); - // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(result, (TContext)context); + await ExecuteAsync(httpContext, path, "text/plain"); // Assert - Mock.Get(result.FileProvider).Verify(); + Mock.Get(webRootFileProvider).Verify(); Assert.Equal(expectedPath, sendFileFeature.Name); } - public static async Task ExecuteResultAsync_WorksWithNonDiskBasedFiles( - Func function) + [Fact] + public async Task ExecuteResultAsync_WorksWithNonDiskBasedFiles() { // Arrange - var httpContext = GetHttpContext(typeof(VirtualFileResultExecutor)); - httpContext.Response.Body = new MemoryStream(); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); var expectedData = "This is an embedded resource"; var sourceStream = new MemoryStream(Encoding.UTF8.GetBytes(expectedData)); @@ -600,14 +451,11 @@ public static async Task ExecuteResultAsync_WorksWithNonDiskBasedFiles var nonDiskFileProvider = new Mock(); nonDiskFileProvider.Setup(fp => fp.GetFileInfo(It.IsAny())).Returns(nonDiskFileInfo.Object); - var filePathResult = new VirtualFileResult("/SampleEmbeddedFile.txt", "text/plain") - { - FileProvider = nonDiskFileProvider.Object - }; + var httpContext = GetHttpContext(nonDiskFileProvider.Object); + httpContext.Response.Body = new MemoryStream(); // Act - object context = typeof(TContext) == typeof(HttpContext) ? httpContext : actionContext; - await function(filePathResult, (TContext)context); + await ExecuteAsync(httpContext, "/SampleEmbeddedFile.txt", "text/plain"); // Assert httpContext.Response.Body.Position = 0; @@ -615,8 +463,8 @@ public static async Task ExecuteResultAsync_WorksWithNonDiskBasedFiles Assert.Equal(expectedData, contents); } - public static async Task ExecuteResultAsync_ThrowsFileNotFound_IfFileProviderCanNotFindTheFile( - Func function) + [Fact] + public async Task ExecuteResultAsync_ThrowsFileNotFound_IfFileProviderCanNotFindTheFile() { // Arrange var path = "TestPath.txt"; @@ -624,44 +472,34 @@ public static async Task ExecuteResultAsync_ThrowsFileNotFound_IfFileProviderCan fileInfo.SetupGet(f => f.Exists).Returns(false); var fileProvider = new Mock(); fileProvider.Setup(f => f.GetFileInfo(path)).Returns(fileInfo.Object); - var filePathResult = new TestVirtualFileResult(path, "text/plain") - { - FileProvider = fileProvider.Object, - }; - var expectedMessage = "Could not find file: " + path; - var actionContext = new ActionContext(GetHttpContext(), new RouteData(), new ActionDescriptor()); + var expectedMessage = $"Could not find file: {path}."; + var httpContext = GetHttpContext(fileProvider.Object); // Act - object context = typeof(TContext) == typeof(HttpContext) ? actionContext.HttpContext : actionContext; - var ex = await Assert.ThrowsAsync(() => function(filePathResult, (TContext)context)); + var ex = await Assert.ThrowsAsync(() => ExecuteAsync(httpContext, path, "text/plain")); // Assert Assert.Equal(expectedMessage, ex.Message); Assert.Equal(path, ex.FileName); } - private static IServiceCollection CreateServices(Type executorType) + private static IServiceCollection CreateServices(IFileProvider webRootFileProvider) { var services = new ServiceCollection(); - var hostingEnvironment = new Mock(); + var hostingEnvironment = Mock.Of(e => e.WebRootFileProvider == webRootFileProvider); - services.AddSingleton, TestVirtualFileResultExecutor>(); - if (executorType != null) - { - services.AddSingleton(typeof(IActionResultExecutor), executorType); - } - - services.AddSingleton(hostingEnvironment.Object); + services.AddSingleton(hostingEnvironment); services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); return services; } - private static HttpContext GetHttpContext(Type executorType = null) + private static HttpContext GetHttpContext(IFileProvider webRootFileProvider) { - var services = CreateServices(executorType); + var services = CreateServices(webRootFileProvider); var httpContext = new DefaultHttpContext(); httpContext.RequestServices = services.BuildServiceProvider(); @@ -669,7 +507,7 @@ private static HttpContext GetHttpContext(Type executorType = null) return httpContext; } - private static IFileProvider GetFileProvider(string path) + protected static IFileProvider GetFileProvider(string path) { var fileInfo = new Mock(); fileInfo.SetupGet(fi => fi.Length).Returns(33); @@ -686,28 +524,6 @@ private static IFileProvider GetFileProvider(string path) return fileProvider.Object; } - private class TestVirtualFileResult : VirtualFileResult - { - public TestVirtualFileResult(string filePath, string contentType) - : base(filePath, contentType) - { - } - - public override Task ExecuteResultAsync(ActionContext context) - { - var executor = (TestVirtualFileResultExecutor)context.HttpContext.RequestServices.GetRequiredService>(); - return executor.ExecuteAsync(context, this); - } - } - - private class TestVirtualFileResultExecutor : VirtualFileResultExecutor - { - public TestVirtualFileResultExecutor(ILoggerFactory loggerFactory, IWebHostEnvironment hostingEnvironment) - : base(loggerFactory, hostingEnvironment) - { - } - } - private class TestSendFileFeature : IHttpResponseBodyFeature { public string Name { get; set; }