(feature): add redirect handling in Result<T>.ToActionResult

This commit is contained in:
Maksym Sadovnychyy 2026-06-28 10:45:15 +02:00
parent 14fe8f2897
commit 60ffad2ea9
10 changed files with 69 additions and 25 deletions

View File

@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.0.3] - 2026-06-28
### Added
- `Result<T>.ToActionResult()` now returns `RedirectResult` when the status code is a redirection (300303, 307308) and `Value` is a non-empty URL string.
- Added `MaksIT.Results.Mvc.RedirectResult` for HTTP redirect responses.
### Changed
- Updated ASP.NET Core and Microsoft.Extensions package references.
## [2.0.2] - 2026-06-02 ## [2.0.2] - 2026-06-02
### Changed ### Changed

View File

@ -82,6 +82,7 @@ public sealed record UserDto(Guid Id, string Name);
- `Result` success: returns status-code-only response. - `Result` success: returns status-code-only response.
- `Result<T>` success with non-null `Value`: returns JSON body + status code. - `Result<T>` success with non-null `Value`: returns JSON body + status code.
- `Result<T>` success with a redirection status code (300303, 307308) and a non-empty string `Value`: returns an HTTP redirect to that URL.
- Any failure: returns RFC 7807-style `ProblemDetails` JSON with: - Any failure: returns RFC 7807-style `ProblemDetails` JSON with:
- `status` = result status code - `status` = result status code
- `title` = `"An error occurred"` - `title` = `"An error occurred"`

View File

@ -1,21 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Branch Coverage: 65.4%"> <svg xmlns="http://www.w3.org/2000/svg" width="147.5" height="20" role="img" aria-label="Branch Coverage: 75%">
<title>Branch Coverage: 65.4%</title> <title>Branch Coverage: 75%</title>
<linearGradient id="s" x2="0" y2="100%"> <linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/> <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/> <stop offset="1" stop-opacity=".1"/>
</linearGradient> </linearGradient>
<clipPath id="r"> <clipPath id="r">
<rect width="150" height="20" rx="3" fill="#fff"/> <rect width="147.5" height="20" rx="3" fill="#fff"/>
</clipPath> </clipPath>
<g clip-path="url(#r)"> <g clip-path="url(#r)">
<rect width="107.5" height="20" fill="#555"/> <rect width="107.5" height="20" fill="#555"/>
<rect x="107.5" width="42.5" height="20" fill="#97ca00"/> <rect x="107.5" width="40" height="20" fill="#97ca00"/>
<rect width="150" height="20" fill="url(#s)"/> <rect width="147.5" height="20" fill="url(#s)"/>
</g> </g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11"> <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Branch Coverage</text> <text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Branch Coverage</text>
<text x="53.75" y="14" fill="#fff">Branch Coverage</text> <text x="53.75" y="14" fill="#fff">Branch Coverage</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">65.4%</text> <text aria-hidden="true" x="127.5" y="15" fill="#010101" fill-opacity=".3">75%</text>
<text x="128.75" y="14" fill="#fff">65.4%</text> <text x="127.5" y="14" fill="#fff">75%</text>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 17.6%"> <svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 19.8%">
<title>Line Coverage: 17.6%</title> <title>Line Coverage: 19.8%</title>
<linearGradient id="s" x2="0" y2="100%"> <linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/> <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/> <stop offset="1" stop-opacity=".1"/>
@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11"> <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="47.25" y="15" fill="#010101" fill-opacity=".3">Line Coverage</text> <text aria-hidden="true" x="47.25" y="15" fill="#010101" fill-opacity=".3">Line Coverage</text>
<text x="47.25" y="14" fill="#fff">Line Coverage</text> <text x="47.25" y="14" fill="#fff">Line Coverage</text>
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">17.6%</text> <text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">19.8%</text>
<text x="115.75" y="14" fill="#fff">17.6%</text> <text x="115.75" y="14" fill="#fff">19.8%</text>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 17.8%"> <svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 19.9%">
<title>Method Coverage: 17.8%</title> <title>Method Coverage: 19.9%</title>
<linearGradient id="s" x2="0" y2="100%"> <linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/> <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/> <stop offset="1" stop-opacity=".1"/>
@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11"> <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Method Coverage</text> <text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Method Coverage</text>
<text x="53.75" y="14" fill="#fff">Method Coverage</text> <text x="53.75" y="14" fill="#fff">Method Coverage</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">17.8%</text> <text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">19.9%</text>
<text x="128.75" y="14" fill="#fff">17.8%</text> <text x="128.75" y="14" fill="#fff">19.9%</text>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -14,10 +14,10 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.10" /> <PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.11" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Options" Version="10.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -45,4 +45,15 @@ public class ResultToActionResultTests {
Assert.Equal((int)HttpStatusCode.OK, objectResult.StatusCode); Assert.Equal((int)HttpStatusCode.OK, objectResult.StatusCode);
Assert.Equal(value, objectResult.Value); Assert.Equal(value, objectResult.Value);
} }
[Fact]
public void ToActionResult_WhenGenericSuccessWithRedirect_ReturnsRedirectResult() {
var result = Result<string>.Found("https://example.com/continue");
var actionResult = result.ToActionResult();
Assert.IsType<RedirectResult>(actionResult);
var redirectResult = (RedirectResult)actionResult;
Assert.Equal("https://example.com/continue", redirectResult.Location);
}
} }

View File

@ -8,7 +8,7 @@
<!-- NuGet package metadata --> <!-- NuGet package metadata -->
<PackageId>MaksIT.Results</PackageId> <PackageId>MaksIT.Results</PackageId>
<Version>2.0.2</Version> <Version>2.0.3</Version>
<Authors>Maksym Sadovnychyy</Authors> <Authors>Maksym Sadovnychyy</Authors>
<Company>MAKS-IT</Company> <Company>MAKS-IT</Company>
<Product>MaksIT.Results</Product> <Product>MaksIT.Results</Product>
@ -22,9 +22,9 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Abstractions" Version="2.3.10" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Abstractions" Version="2.3.11" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Options" Version="10.0.9" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Mvc;
namespace MaksIT.Results.Mvc;
public class RedirectResult(string location) : IActionResult {
public string Location { get; } = location;
public Task ExecuteResultAsync(ActionContext context) {
context.HttpContext.Response.Redirect(Location);
return Task.CompletedTask;
}
}

View File

@ -78,13 +78,24 @@ public partial class Result<T> : Result {
/// <returns>IActionResult that represents the HTTP response.</returns> /// <returns>IActionResult that represents the HTTP response.</returns>
public override IActionResult ToActionResult() { public override IActionResult ToActionResult() {
if (IsSuccess) { if (IsSuccess) {
if (Value is not null) { if (IsRedirectionStatusCode(StatusCode) && Value is string location && !string.IsNullOrWhiteSpace(location))
return new RedirectResult(location);
if (Value is not null)
return new ObjectResult(Value) { StatusCode = (int)StatusCode }; return new ObjectResult(Value) { StatusCode = (int)StatusCode };
}
return base.ToActionResult(); return base.ToActionResult();
} }
else { else {
return base.ToActionResult(); return base.ToActionResult();
} }
} }
private static bool IsRedirectionStatusCode(HttpStatusCode statusCode) =>
statusCode is HttpStatusCode.MultipleChoices
or HttpStatusCode.MovedPermanently
or HttpStatusCode.Found
or HttpStatusCode.SeeOther
or HttpStatusCode.TemporaryRedirect
or HttpStatusCode.PermanentRedirect;
} }