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
Next Next commit
Api Description implementation
Artem-Romanenia committed Jun 10, 2022
commit 70d4f7e1a16bfa003ba8671a1d444c94e6c7747d
13 changes: 12 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;
@@ -10,6 +11,7 @@
using Microsoft.Extensions.Options;
using System.Collections.Generic;
using System.Globalization;
using System.Net;

namespace Ardalis.Result.SampleWeb;

@@ -27,7 +29,16 @@ 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
.Override("POST", HttpStatusCode.Created)
.Override("DELETE", HttpStatusCode.NoContent))
.For(ResultStatus.Error, HttpStatusCode.InternalServerError)
.Remove(ResultStatus.Forbidden)
.Remove(ResultStatus.Unauthorized)
));
services.AddRazorPages();
services.AddLocalization(opt => { opt.ResourcesPath = "Resources"; });
services.Configure<RequestLocalizationOptions>(options =>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;

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

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

namespace Ardalis.Result.AspNetCore
{
public static class MvcOptionsExtensions
{
public static MvcOptions AddDefaultResultConvention(this MvcOptions options)
{
var resultStatusMap = new ResultStatusMap();
resultStatusMap.AddDefaultMap();

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

return options;
}

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;
}
}
}

71 changes: 71 additions & 0 deletions src/Ardalis.Result.AspNetCore/ResultConvention.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.Routing;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading.Tasks;

namespace Ardalis.Result.AspNetCore
{
public class ResultConvention : IActionModelConvention
{
public const string RESULT_STATUS_MAP = "ResultStatusMap";

private readonly List<ResultStatus> _resultStatuses = new List<ResultStatus>();
private readonly ResultStatusMap _map;

public ResultConvention(ResultStatusMap map)
{
_map = map;
}

public void Apply(ActionModel action)
{
action.Properties[RESULT_STATUS_MAP] = _map;

var returnType = action.ActionMethod.ReturnType;

if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(Task<>))
{
returnType = returnType.GetGenericArguments()[0];
}

var isResult = returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(Result<>);
var isVoidResult = (isResult && returnType.GetGenericArguments()[0] == typeof(Result))
|| returnType.IsSubclassOf(typeof(Result<Result>));

if (isResult || isVoidResult)
{
var method = (action.Attributes.FirstOrDefault(a => a is HttpMethodAttribute) as HttpMethodAttribute)?.HttpMethods.FirstOrDefault();

var successStatusCode = !string.IsNullOrEmpty(method) && _map.ContainsKey(ResultStatus.Ok)
? _map[ResultStatus.Ok].GetStatusCode(method)
: HttpStatusCode.OK;
var successType = isVoidResult ? typeof(void) : returnType.GetGenericArguments()[0];

AddProducesResponseTypeAttribute(action.Filters, (int)successStatusCode, successType);

var attr = action.Filters.SingleOrDefault(f => f is ExpectedFailureResultStatusesAttribute) as ExpectedFailureResultStatusesAttribute;
var resultStatuses = attr?.ResultStatuses ?? _map.Keys;

foreach (var status in resultStatuses.Where(s => _map.ContainsKey(s)))
{
var info = _map[status];
AddProducesResponseTypeAttribute(action.Filters, (int)info.GetStatusCode(method), info.ResponseType);
}
}
}

private void AddProducesResponseTypeAttribute(IList<IFilterMetadata> filters, int statusCode, Type responseType)
{
if (!filters.Any(f => f is IApiResponseMetadataProvider rmp && rmp.StatusCode == statusCode))
{
filters.Add(responseType == null ? new ProducesResponseTypeAttribute(statusCode) : new ProducesResponseTypeAttribute(responseType, statusCode));
}
}
}
}
49 changes: 14 additions & 35 deletions src/Ardalis.Result.AspNetCore/ResultExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using System;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;

namespace Ardalis.Result.AspNetCore
{
@@ -59,42 +57,23 @@ public static ActionResult ToActionResult(this ControllerBase controller,

internal static ActionResult ToActionResult(this ControllerBase controller, IResult result)
{
var resultStatusMap = (controller.ControllerContext.ActionDescriptor.Properties[ResultConvention.RESULT_STATUS_MAP] as ResultStatusMap)
?? new ResultStatusMap().AddDefaultMap();

var resultStatusOptions = resultStatusMap[result.Status];
var statusCode = (int)resultStatusOptions.GetStatusCode(controller.HttpContext.Request.Method);

switch (result.Status)
{
case ResultStatus.Ok: return typeof(Result).IsInstanceOfType(result)
? (ActionResult)controller.Ok()
: controller.Ok(result.GetValue());
case ResultStatus.NotFound: return controller.NotFound();
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);
case ResultStatus.Ok:
return typeof(Result).IsInstanceOfType(result)
? (ActionResult)controller.StatusCode(statusCode)
: controller.StatusCode(statusCode, result.GetValue());
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 resultStatusOptions.ResponseType == null
? (ActionResult)controller.StatusCode(statusCode)
: controller.StatusCode(statusCode, resultStatusOptions.GetResponseObject(result));
}

return controller.BadRequest(controller.ModelState);
}

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()
});
}
}
}
111 changes: 111 additions & 0 deletions src/Ardalis.Result.AspNetCore/ResultStatusMap.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Net;
using System.Text;

namespace Ardalis.Result.AspNetCore
{
public class ResultStatusMap : Dictionary<ResultStatus, ResultStatusOptions>
{
public ResultStatusMap AddDefaultMap()
{
return For(ResultStatus.Ok, HttpStatusCode.OK)
.For(ResultStatus.Error, (HttpStatusCode)422, result => {
var details = new StringBuilder("Next error(s) occured:");

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

return new ProblemDetails
{
Title = "Something went wrong.",
Detail = details.ToString()
};
})
.For(ResultStatus.Forbidden, HttpStatusCode.Forbidden)
.For(ResultStatus.Unauthorized, HttpStatusCode.Unauthorized)
.For(ResultStatus.Invalid, HttpStatusCode.BadRequest, typeof(IEnumerable<string>), result => {
var errors = new List<string>();

foreach (var error in result.ValidationErrors) errors.Add(error.ErrorMessage);

return errors;
})
.For(ResultStatus.NotFound, HttpStatusCode.NotFound);
}

public ResultStatusMap For(ResultStatus status, HttpStatusCode defaultStatusCode, Action<ResultStatusOptions> configure)
{
var info = new ResultStatusOptions(status, defaultStatusCode);
configure(info);

this[status] = info;
return this;
}

public ResultStatusMap For(ResultStatus status, HttpStatusCode statusCode)
{
this[status] = new ResultStatusOptions(status, statusCode);
return this;
}

public ResultStatusMap For<T>(ResultStatus status, HttpStatusCode statusCode, Func<IResult, T> getResponseObject)
{
this[status] = new ResultStatusOptions(status, statusCode, typeof(T), result => getResponseObject(result));
return this;
}

public ResultStatusMap For(ResultStatus status, HttpStatusCode statusCode, Type responseType, Func<IResult, object> getResponseObject)
{
this[status] = new ResultStatusOptions(status, statusCode, responseType, getResponseObject);
return this;
}

public new ResultStatusMap Remove(ResultStatus status)
{
base.Remove(status);
return this;
}
}

public class ResultStatusOptions
{
private Dictionary<string, HttpStatusCode> _methodToStatusMap = new Dictionary<string, HttpStatusCode>();
private HttpStatusCode _defaultStatusCode;

public ResultStatusOptions(ResultStatus status, HttpStatusCode defaultStatusCode)
{
_defaultStatusCode = defaultStatusCode;

Status = status;
}

public ResultStatusOptions(ResultStatus status, HttpStatusCode defaultStatusCode, Type responseType, Func<IResult, object> getResponseObject)
{
_defaultStatusCode = defaultStatusCode;

Status = status;
ResponseType = responseType;
GetResponseObject = getResponseObject;
}

public ResultStatus Status { get; }
public Type ResponseType { get; private set; }
public Func<IResult, object> GetResponseObject { get; }

public HttpStatusCode GetStatusCode(string method)
{
method = method.ToLower();

if (!_methodToStatusMap.ContainsKey(method)) return _defaultStatusCode;

return _methodToStatusMap[method];
}

public ResultStatusOptions Override(string method, HttpStatusCode statusCode)
{
_methodToStatusMap[method.ToLower()] = statusCode;
return this;
}
}
}