Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Features/api description #92

Merged
merged 14 commits into from
Feb 6, 2023
6 changes: 6 additions & 0 deletions Ardalis.Result.sln
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ardalis.Result.Sample.Core"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ardalis.Result.FluentValidation", "src\Ardalis.Result.FluentValidation\Ardalis.Result.FluentValidation.csproj", "{43443B4B-2305-4CDF-A7A4-32A80ED1D19B}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ardalis.Result.AspNetCore.UnitTests", "tests\Ardalis.Result.AspNetCore.UnitTests\Ardalis.Result.AspNetCore.UnitTests.csproj", "{0BDA82F9-3602-4705-8DCD-A4724CAAF800}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ardalis.Result.SampleMinimalApi", "sample\Ardalis.Result.SampleMinimalApi\Ardalis.Result.SampleMinimalApi.csproj", "{60860685-37BB-47D9-B0DC-6FE7F0DB2AE5}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ardalis.Result.SampleMinimalApi.FunctionalTests", "sample\Ardalis.Result.SampleMinimalApi.FunctionalTests\Ardalis.Result.SampleMinimalApi.FunctionalTests.csproj", "{A9769533-C9B2-4AD4-8B24-70C474D8EBB0}"
Expand Down Expand Up @@ -67,6 +69,10 @@ Global
{43443B4B-2305-4CDF-A7A4-32A80ED1D19B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{43443B4B-2305-4CDF-A7A4-32A80ED1D19B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{43443B4B-2305-4CDF-A7A4-32A80ED1D19B}.Release|Any CPU.Build.0 = Release|Any CPU
{0BDA82F9-3602-4705-8DCD-A4724CAAF800}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0BDA82F9-3602-4705-8DCD-A4724CAAF800}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0BDA82F9-3602-4705-8DCD-A4724CAAF800}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0BDA82F9-3602-4705-8DCD-A4724CAAF800}.Release|Any CPU.Build.0 = Release|Any CPU
{60860685-37BB-47D9-B0DC-6FE7F0DB2AE5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{60860685-37BB-47D9-B0DC-6FE7F0DB2AE5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{60860685-37BB-47D9-B0DC-6FE7F0DB2AE5}.Release|Any CPU.ActiveCfg = Release|Any CPU
Expand Down
72 changes: 72 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,78 @@ public ActionResult<WeatherForecastSummaryDto> CreateSummaryForecast([FromBody]
}
```

## Asp Net API Metadata

By default, Asp Net Core and Api Explorer know nothing about `[TranslateResultToActionResult]` and what it is doing. To reflect `[TranslateResultToActionResult]` behavior in metadata generated by Api Explorer (which is then used by tools like Swashbuckle, NSwag etc.), you can use `ResultConvention`:

```csharp
services.AddControllers(mvcOptions => mvcOptions.AddDefaultResultConvention());
```

This will add `[ProducesResponseType]` for every known `ResultStatus` to every endpoint marked with `[TranslateResultToActionResult]`.
To customize ResultConvention behavior, one may use `AddResultConvention` method:

```csharp
services.AddControllers(mvcOptions => mvcOptions
.AddResultConvention(resultStatusMap => resultStatusMap
.AddDefaultMap()
));
```

This code is functionally equivalent to previous example.

From here you can modify ResultStatus-to-HttpStatusCode mapping

```csharp
services.AddControllers(mvcOptions => mvcOptions
.AddResultConvention(resultStatusMap => resultStatusMap
.AddDefaultMap()
.For(ResultStatus.Ok, HttpStatusCode.OK, resultStatusOptions => resultStatusOptions
.For("POST", HttpStatusCode.Created)
.For("DELETE", HttpStatusCode.NoContent))
.For(ResultStatus.Error, HttpStatusCode.InternalServerError)
));
```

`ResultConvention` will add `[ProducesResponseType]` for every result status configured in `ResultStatusMap`. `AddDefaultMap()` maps every known ResultType, so if you want to exclude certain ResultType from being listed (e.g. your app doesn't have authentication and authorization, and you don't want 401 and 403 to be listed as `SupportedResponseType`) you can do this:

```csharp
services.AddControllers(mvcOptions => mvcOptions
.AddResultConvention(resultStatusMap => resultStatusMap
.AddDefaultMap()
.For(ResultStatus.Ok, HttpStatusCode.OK, resultStatusOptions => resultStatusOptions
.For("POST", HttpStatusCode.Created)
.For("DELETE", HttpStatusCode.NoContent))
.Remove(ResultStatus.Forbidden)
.Remove(ResultStatus.Unauthorized)
));
```

Alternatively, you can specify which (failure) result statuses are expected from certain endpoint:

```csharp
[TranslateResultToActionResult()]
[ExpectedFailures(ResultStatus.NotFound, ResultStatus.Invalid)]
[HttpDelete("Remove/{id}")]
public Result RemovePerson(int id)
{
// Method logic
}
```

`!!!` If you list certain result status in `ExpectedFailures`, it must be configured in `ResultConvention` on startup.

Another configurability feature is what part of Result object is returned in case of specific failure:

```csharp
services.AddControllers(mvcOptions => mvcOptions
.AddResultConvention(resultStatusMap => resultStatusMap
.AddDefaultMap()
.For(ResultStatus.Error, HttpStatusCode.BadRequest, resultStatusOptions => resultStatusOptions
.With((ctrlr, result) => string.Join("\r\n", result.ValidationErrors)))
));
```

### Using Results with FluentValidation

We can use Ardalis.Result.FluentValidation on a service with FluentValidation like that:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Testing;
using Newtonsoft.Json;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
Expand Down Expand Up @@ -43,7 +45,12 @@ public async Task ReturnsNotFoundGivenUnknownId(string route)

Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Person Not Found", stringResponse);

var problemDetails = JsonConvert.DeserializeObject<ProblemDetails>(stringResponse);

Assert.Contains("Resource not found.", problemDetails.Title);
Assert.Contains("Person Not Found", problemDetails.Detail);
Assert.Equal(404, problemDetails.Status);
}

private async Task<HttpResponseMessage> SendDeleteRequest(string route, int id)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Ardalis.Result.Sample.Core.DTOs;
using Ardalis.Result.Sample.Core.Model;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Testing;
using Newtonsoft.Json;
using System.Collections.Generic;
Expand Down Expand Up @@ -47,6 +48,12 @@ public async Task ReturnsBadRequestGivenNoPostalCode(string route)
var response = await PostDTOAndGetResponse(requestDto, route);

Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var stringResponse = await response.Content.ReadAsStringAsync();

var validationProblemDetails = JsonConvert.DeserializeObject<ValidationProblemDetails>(stringResponse);

Assert.Contains(validationProblemDetails.Errors, d => d.Key == nameof(ForecastRequestDto.PostalCode));
Assert.Equal(400, validationProblemDetails.Status);
}

[Theory]
Expand All @@ -58,6 +65,12 @@ public async Task ReturnsNotFoundGivenNonExistentPostalCode(string route)
var response = await PostDTOAndGetResponse(requestDto, route);

Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var stringResponse = await response.Content.ReadAsStringAsync();

var problemDetails = JsonConvert.DeserializeObject<ProblemDetails>(stringResponse);

Assert.Equal("Resource not found.", problemDetails.Title);
Assert.Equal(404, problemDetails.Status);
}

[Theory]
Expand All @@ -70,7 +83,12 @@ public async Task ReturnsBadRequestGivenPostalCodeTooLong(string route)

Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("PostalCode cannot exceed 10 characters.", stringResponse);

var validationProblemDetails = JsonConvert.DeserializeObject<ValidationProblemDetails>(stringResponse);

Assert.Contains(validationProblemDetails.Errors, d => d.Key == nameof(ForecastRequestDto.PostalCode));
Assert.Contains(validationProblemDetails.Errors[nameof(ForecastRequestDto.PostalCode)], e => e.Equals("PostalCode cannot exceed 10 characters.", System.StringComparison.OrdinalIgnoreCase));
Assert.Equal(400, validationProblemDetails.Status);
}

private async Task<HttpResponseMessage> PostDTOAndGetResponse(ForecastRequestDto dto, string route)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public PersonController(PersonService personService)
/// <param name="model"></param>
/// <returns></returns>
[TranslateResultToActionResult]
[ExpectedFailures(ResultStatus.NotFound, ResultStatus.Invalid)]
[HttpDelete("Remove/{id}")]
public Result RemovePerson(int id)
{
Expand Down
12 changes: 11 additions & 1 deletion sample/Ardalis.Result.SampleWeb/Startup.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Ardalis.Result.AspNetCore;
using Ardalis.Result.Sample.Core.Services;
using Ardalis.Result.SampleWeb.MediatrApi;
using MediatR;
Expand All @@ -10,6 +11,7 @@
using Microsoft.Extensions.Options;
using System.Collections.Generic;
using System.Globalization;
using System.Net;

namespace Ardalis.Result.SampleWeb;

Expand All @@ -27,7 +29,15 @@ public void ConfigureServices(IServiceCollection services)
var webAssembly = typeof(Startup).Assembly;
services.AddMediatR(webAssembly);
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
services.AddControllers();
services.AddControllers(mvcOptions => mvcOptions
.AddResultConvention(resultStatusMap => resultStatusMap
.AddDefaultMap()
.For(ResultStatus.Ok, HttpStatusCode.OK, resultStatusOptions => resultStatusOptions
.For("POST", HttpStatusCode.Created)
.For("DELETE", HttpStatusCode.NoContent))
.Remove(ResultStatus.Forbidden)
.Remove(ResultStatus.Unauthorized)
));
services.AddRazorPages();
services.AddLocalization(opt => { opt.ResourcesPath = "Resources"; });
services.Configure<RequestLocalizationOptions>(options =>
Expand Down
67 changes: 15 additions & 52 deletions src/Ardalis.Result.AspNetCore/ActionResultExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System;
using System.Linq;
using System.Text;
using Microsoft.AspNetCore.Mvc;

Expand Down Expand Up @@ -60,61 +59,25 @@ public static ActionResult ToActionResult(this ControllerBase controller,

internal static ActionResult ToActionResult(this ControllerBase controller, IResult result)
{
switch (result.Status)
{
case ResultStatus.Ok: return typeof(Result).IsInstanceOfType(result)
? (ActionResult)controller.Ok()
: controller.Ok(result.GetValue());
case ResultStatus.NotFound: return NotFoundEntity(controller, result);
case ResultStatus.Unauthorized: return controller.Unauthorized();
case ResultStatus.Forbidden: return controller.Forbid();
case ResultStatus.Invalid: return BadRequest(controller, result);
case ResultStatus.Error: return UnprocessableEntity(controller, result);
default:
throw new NotSupportedException($"Result {result.Status} conversion is not supported.");
}
}

private static ActionResult BadRequest(ControllerBase controller, IResult result)
{
foreach (var error in result.ValidationErrors)
{
controller.ModelState.AddModelError(error.Identifier, error.ErrorMessage);
}

return controller.BadRequest(controller.ModelState);
}
var actionProps = controller.ControllerContext.ActionDescriptor.Properties;

private static ActionResult UnprocessableEntity(ControllerBase controller, IResult result)
{
var details = new StringBuilder("Next error(s) occured:");

foreach (var error in result.Errors) details.Append("* ").Append(error).AppendLine();

return controller.UnprocessableEntity(new ProblemDetails
{
Title = "Something went wrong.",
Detail = details.ToString()
});
}
var resultStatusMap = actionProps.ContainsKey(ResultConvention.RESULT_STATUS_MAP_PROP)
?(actionProps[ResultConvention.RESULT_STATUS_MAP_PROP] as ResultStatusMap)
: new ResultStatusMap().AddDefaultMap();

private static ActionResult NotFoundEntity(ControllerBase controller, IResult result)
{
var details = new StringBuilder("Next error(s) occured:");
var resultStatusOptions = resultStatusMap[result.Status];
var statusCode = (int)resultStatusOptions.GetStatusCode(controller.HttpContext.Request.Method);

if (result.Errors.Any())
{
foreach (var error in result.Errors) details.Append("* ").Append(error).AppendLine();

return controller.NotFound(new ProblemDetails
{
Title = "Resource not found.",
Detail = details.ToString()
});
}
else
switch (result.Status)
{
return controller.NotFound();
case ResultStatus.Ok:
return typeof(Result).IsInstanceOfType(result)
? (ActionResult)controller.StatusCode(statusCode)
: controller.StatusCode(statusCode, result.GetValue());
default:
return resultStatusOptions.ResponseType == null
? (ActionResult)controller.StatusCode(statusCode)
: controller.StatusCode(statusCode, resultStatusOptions.GetResponseObject(controller, result));
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;

namespace Ardalis.Result.AspNetCore.Exceptions
{
internal class UnexpectedFailureResultsException : Exception
{
public UnexpectedFailureResultsException(IEnumerable<ResultStatus> statuses)
{
UnexpectedStatuses = statuses;
}

public IEnumerable<ResultStatus> UnexpectedStatuses { get; }

public override string ToString()
{
return $"ActionModel has [{nameof(ExpectedFailuresAttribute)}] with result statuses which are not configured in ResultConvention.";
}
}
}
16 changes: 16 additions & 0 deletions src/Ardalis.Result.AspNetCore/ExpectedFailuresAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;

namespace Ardalis.Result.AspNetCore
{
[AttributeUsage(AttributeTargets.Method)]
public class ExpectedFailuresAttribute : Attribute
{
public ExpectedFailuresAttribute(params ResultStatus[] resultStatuses)
{
ResultStatuses = resultStatuses;
}

public IEnumerable<ResultStatus> ResultStatuses { get; }
}
}
41 changes: 41 additions & 0 deletions src/Ardalis.Result.AspNetCore/MvcOptionsExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using System;

namespace Ardalis.Result.AspNetCore
{
public static class MvcOptionsExtensions
{
/// <summary>
/// Adds <see cref="ResultConvention"/> which generates <see cref="ProducesResponseTypeAttribute"/>s
/// for every endpoint marked with <see cref="TranslateResultToActionResultAttribute"/>
/// based on default configuration
/// </summary>
public static MvcOptions AddDefaultResultConvention(this MvcOptions options)
{
var resultStatusMap = new ResultStatusMap();
resultStatusMap.AddDefaultMap();

options.Conventions.Add(new ResultConvention(resultStatusMap));

return options;
}

/// <summary>
/// Adds <see cref="ResultConvention"/> which generates <see cref="ProducesResponseTypeAttribute"/>s
/// for every endpoint marked with <see cref="TranslateResultToActionResultAttribute"/>
/// based on provided configuration
/// </summary>
/// <param name="configure">A <see cref="Action"/> to map <see cref="ResultStatus"/>es to <see cref="System.Net.HttpStatusCode"/>s</param>
public static MvcOptions AddResultConvention(this MvcOptions options, Action<ResultStatusMap> configure = null)
{
var resultStatusMap = new ResultStatusMap();
configure?.Invoke(resultStatusMap);

options.Conventions.Add(new ResultConvention(resultStatusMap));

return options;
}
}
}

Loading