-
Notifications
You must be signed in to change notification settings - Fork 94
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
[Enhancement] Add extension methods for to convert to IActionResult (mvc) IResult (minimal API) #32
Comments
Maybe as a workaround (if this won't get implemented) you could use the really convenient e.g
where the |
Hello everyone public static partial class ErrorOrAspNetCoreExtensions
{
public static IActionResult ToActionResult(this Error error) => error switch
{
{ Type: not(ErrorType.Failure or ErrorType.Unexpected) } => MatchCommonErrorResults(error),
_ => new StatusCodeResult(StatusCodes.Status500InternalServerError)
};
public static IActionResult ToActionResult(
this Error error,
int? statusCode = null,
string? title = null,
string? detail = null,
string? instance = null,
string? type = null) => error switch
{
{ Type: not(ErrorType.Failure or ErrorType.Unexpected) } => MatchCommonErrorResults(error),
_ => MatchProblemResult(
statusCode,
title,
detail,
instance,
type),
};
public static OkObjectResult ToOk<TValue>(this ErrorOr<TValue> errorOr)
{
EnsureStateIsNotError(errorOr, nameof(OkObjectResult));
return new(errorOr.Value);
}
public static CreatedResult ToCreated<TValue>(
this ErrorOr<TValue> errorOr,
string uri)
{
EnsureStateIsNotError(errorOr, nameof(CreatedResult));
return new(uri, errorOr.Value);
}
public static CreatedResult ToCreated<TValue>(
this ErrorOr<TValue> errorOr,
Uri uri)
{
EnsureStateIsNotError(errorOr, nameof(CreatedResult));
return new(uri, errorOr);
}
public static CreatedAtActionResult ToCreatedAtAction<TValue>(
this ErrorOr<TValue> errorOr,
string actionName,
string controllerName,
object routeValues)
{
EnsureStateIsNotError(errorOr, nameof(CreatedAtActionResult));
return new(actionName, controllerName, routeValues, errorOr);
}
public static CreatedAtRouteResult ToCreatedAtRoute<TValue>(
this ErrorOr<TValue> errorOr,
object routeValues)
{
EnsureStateIsNotError(errorOr, nameof(CreatedAtRouteResult));
return new(routeValues, errorOr);
}
public static CreatedAtRouteResult ToCreatedAtRoute<TValue>(
this ErrorOr<TValue> errorOr,
string routeName,
object routeValues)
{
EnsureStateIsNotError(errorOr, nameof(CreatedAtRouteResult));
return new(routeName, routeValues, errorOr);
}
public static AcceptedResult ToAccepted<TValue>(this ErrorOr<TValue> errorOr)
{
EnsureStateIsNotError(errorOr, nameof(AcceptedResult));
return new(location: null, errorOr);
}
public static AcceptedResult ToAccepted<TValue>(
this ErrorOr<TValue> errorOr,
string uri)
{
EnsureStateIsNotError(errorOr, nameof(AcceptedResult));
return new(uri, errorOr);
}
public static AcceptedResult ToAccepted<TValue>(
this ErrorOr<TValue> errorOr,
Uri uri)
{
EnsureStateIsNotError(errorOr, nameof(AcceptedResult));
return new(uri, errorOr);
}
private static void EnsureStateIsNotError<TValue>(
ErrorOr<TValue> errorOr,
string targetTypeName)
{
if (errorOr.IsError)
{
string message = $"Cannot convert to '{targetTypeName}' because the state is error";
throw new InvalidOperationException(message);
}
}
private static IActionResult MatchCommonErrorResults(Error error) => error switch
{
{ Type: ErrorType.NotFound } => new NotFoundResult(),
{ Type: ErrorType.Validation } => new BadRequestResult(),
{ Type: ErrorType.Unauthorized } => new UnauthorizedResult(),
{ Type: ErrorType.Conflict } => new ConflictResult(),
_ => throw new ArgumentException("Invalid error type!", nameof(error)),
};
private static ObjectResult MatchProblemResult(
int? statusCode,
string? title = null,
string? detail = null,
string? instance = null,
string? type = null)
{
ProblemDetails problem = new()
{
Status = statusCode ?? StatusCodes.Status500InternalServerError,
Instance = instance,
Detail = detail,
Title = title,
Type = type,
};
return new(problem);
}
} |
By the way, I was thinking about having extension methods for minimal APIs. but there is a problem, public static IActionResult ToOk<TValue>(this ErrorOr<TValue> errorOr)
{
return new OkObjectResult(errorOr.Value);
} public static IResult ToOk<TValue>(this ErrorOr<TValue> errorOr)
{
return Results.Ok(errorOr.value);
} how do you think we should work this around ? |
This is how I solved it. |
The method I've settled on is to create an Error extension method ToActionResult that sits in the API layer. Love the library! [Authorize(Roles = AuthConstants.Roles.Jobs.Read)]
[HttpGet(ApiEndpoints.Jobs.GetJobByCode.Url)]
public async Task<IActionResult> GetJobByCode(string? jobCode, CancellationToken token)
{
var getJobResult =
await _mediator.Send(new GetJobQuery(HttpContext.ToExecutionContext(), string.Empty, jobCode), token);
if (getJobResult.IsError) return getJobResult.FirstError.ToActionResult();
var job = getJobResult.Value;
var jobResponse = job.ToJobResponse();
return Ok(jobResponse);
} public static class ActionResultMapper
{
public static IActionResult ToActionResult(this Error error)
{
var problemDetails = new ProblemDetails
{
Title = error.Type.ToString(),
Extensions = { ["code"] = error.Code },
Detail = error.Description
};
return error.Type switch
{
ErrorType.Validation => new ObjectResult(problemDetails) { StatusCode = StatusCodes.Status400BadRequest },
ErrorType.NotFound => new ObjectResult(problemDetails) { StatusCode = StatusCodes.Status404NotFound },
ErrorType.Unauthorized => new ObjectResult(problemDetails)
{ StatusCode = StatusCodes.Status401Unauthorized },
ErrorType.Forbidden => new ObjectResult(problemDetails) { StatusCode = StatusCodes.Status403Forbidden },
_ => GenerateGeneric500Error()
};
}
private static IActionResult GenerateGeneric500Error()
{
var genericProblemDetails = new ProblemDetails
{
Title = "Internal Server Error",
Detail = "An unexpected error occurred.",
Status = StatusCodes.Status500InternalServerError
};
return new ObjectResult(genericProblemDetails) { StatusCode = StatusCodes.Status500InternalServerError };
}
} |
Thanks for the suggestions everyone. I started looking into implementing this feature, as I think it can be very useful for ASP.NET apps. For minimal APIs the solution is simple and could look something like this: public static class ErrorExtensions
{
public static IResult ToProblemResult(this List<Error> errors)
{
if (errors.Count is 0)
{
return TypedResults.Problem();
}
if (errors.All(error => error.Type == ErrorType.Validation))
{
return errors.ToValidationProblemResult();
}
return errors[0].ToProblemResult();
}
private static ProblemHttpResult ToProblemResult(this Error error)
{
var statusCode = error.Type switch
{
ErrorType.Conflict => StatusCodes.Status409Conflict,
ErrorType.Validation => StatusCodes.Status400BadRequest,
ErrorType.NotFound => StatusCodes.Status404NotFound,
ErrorType.Unauthorized => StatusCodes.Status403Forbidden,
_ => StatusCodes.Status500InternalServerError,
};
return TypedResults.Problem(statusCode: statusCode, title: error.Description);
}
private static ValidationProblem ToValidationProblemResult(this List<Error> errors)
{
Dictionary<string, string[]> validationErrors = errors
.GroupBy(error => error.Code)
.ToDictionary(
group => group.Key,
group => group.Select(error => error.Description).ToArray());
return TypedResults.ValidationProblem(validationErrors);
}
} Which takes into account any custom extensions or configurations defined for the problem details: builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = (context) =>
{
context.ProblemDetails.Extensions.Add("foo", "asdfasdfasdf");
};
}); The problem still remains for controllers, since the problem details customizations are applied via the One option is doing what @xavierjohn did, which is passing the controller base to the which doesn't sit right with me. A better option in this case IMO is passing the HttpContext (which we would then use to get hold of the problem details factory/service) Here are the options I'm thinking aboutOptions 1: Making an extension method that turns the errors into a
|
Here is a full working implementation for both MVC controllers and minimal APIs: public static class ErrorExtensions
{
public static IActionResult ToProblemResult(this List<Error> errors, HttpContext httpContext)
{
ProblemDetails problemDetails = errors.ToProblemDetails();
if (httpContext.RequestServices.GetService<ProblemDetailsFactory>() is ProblemDetailsFactory factory)
{
problemDetails = factory.CreateProblemDetails(
httpContext,
problemDetails.Status,
problemDetails.Title,
problemDetails.Type,
problemDetails.Detail,
problemDetails.Instance);
}
return new ObjectResult(problemDetails) { StatusCode = problemDetails.Status };
}
public static IResult ToProblemResult(this List<Error> errors)
{
return Results.Problem(errors.ToProblemDetails());
}
public static ProblemDetails ToProblemDetails(this List<Error> errors)
{
if (errors.Count is 0)
{
return TypedResults.Problem().ProblemDetails;
}
if (errors.All(error => error.Type == ErrorType.Validation))
{
return errors.ToValidationProblemResult().ProblemDetails;
}
return errors[0].ToProblemResult().ProblemDetails;
}
private static ProblemHttpResult ToProblemResult(this Error error)
{
var statusCode = error.Type switch
{
ErrorType.Conflict => StatusCodes.Status409Conflict,
ErrorType.Validation => StatusCodes.Status400BadRequest,
ErrorType.NotFound => StatusCodes.Status404NotFound,
ErrorType.Unauthorized => StatusCodes.Status403Forbidden,
_ => StatusCodes.Status500InternalServerError,
};
return TypedResults.Problem(statusCode: statusCode, title: error.Description);
}
private static ValidationProblem ToValidationProblemResult(this List<Error> errors)
{
Dictionary<string, string[]> validationErrors = errors
.GroupBy(error => error.Code)
.ToDictionary(
group => group.Key,
group => group.Select(error => error.Description).ToArray());
return TypedResults.ValidationProblem(validationErrors);
}
} Waiting to hear some feedback before I move forward with this 🙂 |
@amantinband I think that looks great 👍 In regards to your earlier question about this being in a separate |
For me option 2, and ToProblemResult (for both) For context I should mention that I haven't used ErrorOr yet, but have used the Ardalis Result library. |
@amantinband looks good. I wonder, maybe this is a bigger question. In my scenario for conflicts and bad requests I need for each error three properties.
This is so we can inform our customers what is wrong, and each consumer of the API can choose what error message to display. They don't have to parse the error message to understand what the issue is. So to my question: Can we have the ability to change the structure of the errors. and or introduce each error in your system to include a 'code' property? |
Yes, this package would be great! |
@amantinband looks great! IMO, |
Hey guys, here's an early draft for the new APIs I'm thinking of adding as part of ErrorOr.AspNetCore: Convert an
|
initial draft #107 |
I see that the PR is barely readable (will fix it up), so here's a tl;dr of the planned changes and the APIs that will be available as part of ErrorOr.AspNetCore: ErrorOr configurationsSmall examplebuilder.Services.AddErrorOr(options =>
{
options.IncludeMetadataInProblemDetails = true;
options.ErrorToResultMapper.Add(error =>
{
return error.NumericType == 421
? Results.Ok("this is actually not an error")
: null;
});
options.ErrorToStatusCodeMapper.Add(error =>
{
return error.NumericType switch
{
421 => StatusCodes.Status307TemporaryRedirect,
68 => StatusCodes.Status202Accepted,
_ => null,
};
});
}); All optionspublic class ErrorOrOptions
{
public bool IncludeMetadataInProblemDetails { get; set; } = false; // whether error's metadata will be included in the problem details response
public List<Func<List<Error>, IResult?>> ErrorListToResultMapper { get; set; } = [];
public List<Func<Error, IResult?>> ErrorToResultMapper { get; set; } = [];
public List<Func<List<Error>, IActionResult?>> ErrorListToActionResultMapper { get; set; } = [];
public List<Func<Error, IActionResult?>> ErrorToActionResultMapper { get; set; } = [];
public List<Func<List<Error>, ProblemDetails?>> ErrorListToProblemDetailsMapper { get; set; } = [];
public List<Func<Error, ProblemDetails?>> ErrorToProblemDetailsMapper { get; set; } = [];
public List<Func<Error, int?>> ErrorToStatusCodeMapper { get; set; } = [];
public List<Func<Error, string?>> ErrorToTitleMapper { get; set; } = [];
}
|
Dear Amichai, I had a quick look at your implementation yesterday evening and I would like to put some implementation details up for discussion. First of all, having this ASP.NET Core integration will be a huge win! We should also strife for other frameworks like GRPC's About the current implementation:
Enough from me, now I'm really interested in your opinion. How do you feel about this? |
Fantastic feedback, thanks for taking the time.
Let me play around with the syntax and naming a bit. A major selling point of ErrorOr is the intuitive naming, we don't want to be out of character 🤙 Please drop any suggestions you may have. Thanks! |
I will soon provide a PR to you and then we can discuss the details. |
After further inspecting the source code and the ProblemDetailsFactory functionality, I think I found an issue with the current implementation. Here's the ProblemDetailsFactory definition: public abstract class ProblemDetailsFactory
{
public abstract ProblemDetails CreateProblemDetails(
HttpContext httpContext,
int? statusCode = null,
string? title = null,
string? type = null,
string? detail = null,
string? instance = null
);
public abstract ValidationProblemDetails CreateValidationProblemDetails(
HttpContext httpContext,
ModelStateDictionary modelStateDictionary,
int? statusCode = null,
string? title = null,
string? type = null,
string? detail = null,
string? instance = null
);
} The only out-of-the-box implementation is internal sealed class DefaultProblemDetailsFactory : ProblemDetailsFactory
{
private readonly ApiBehaviorOptions _options;
private readonly Action<ProblemDetailsContext>? _configure;
public DefaultProblemDetailsFactory(
IOptions<ApiBehaviorOptions> options,
IOptions<ProblemDetailsOptions>? problemDetailsOptions = null)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_configure = problemDetailsOptions?.Value?.CustomizeProblemDetails;
}
public override ProblemDetails CreateProblemDetails(
HttpContext httpContext,
int? statusCode = null,
string? title = null,
string? type = null,
string? detail = null,
string? instance = null)
{
statusCode ??= 500;
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Type = type,
Detail = detail,
Instance = instance,
};
ApplyProblemDetailsDefaults(httpContext, problemDetails, statusCode.Value);
return problemDetails;
}
public override ValidationProblemDetails CreateValidationProblemDetails(
HttpContext httpContext,
ModelStateDictionary modelStateDictionary,
int? statusCode = null,
string? title = null,
string? type = null,
string? detail = null,
string? instance = null)
{
ArgumentNullException.ThrowIfNull(modelStateDictionary);
statusCode ??= 400;
var problemDetails = new ValidationProblemDetails(modelStateDictionary)
{
Status = statusCode,
Type = type,
Detail = detail,
Instance = instance,
};
if (title != null)
{
// For validation problem details, don't overwrite the default title with null.
problemDetails.Title = title;
}
ApplyProblemDetailsDefaults(httpContext, problemDetails, statusCode.Value);
return problemDetails;
}
private void ApplyProblemDetailsDefaults(HttpContext httpContext, ProblemDetails problemDetails, int statusCode)
{
problemDetails.Status ??= statusCode;
if (_options.ClientErrorMapping.TryGetValue(statusCode, out var clientErrorData))
{
problemDetails.Title ??= clientErrorData.Title;
problemDetails.Type ??= clientErrorData.Link;
}
var traceId = Activity.Current?.Id ?? httpContext?.TraceIdentifier;
if (traceId != null)
{
problemDetails.Extensions["traceId"] = traceId;
}
_configure?.Invoke(new() { HttpContext = httpContext!, ProblemDetails = problemDetails });
}
} As we can see, a Our public static IActionResult ToActionResult(this List<Error> errors, HttpContext? httpContext = null)
{
foreach (var mapping in ErrorOrOptions.Instance.ErrorListToActionResultMapper)
{
if (mapping(errors) is IActionResult actionResult)
{
return actionResult;
}
}
ProblemDetails problemDetails = errors.ToProblemDetails();
if (httpContext?.RequestServices.GetService<ProblemDetailsFactory>() is ProblemDetailsFactory factory)
{
problemDetails = factory.CreateProblemDetails(
httpContext,
problemDetails.Status,
problemDetails.Title,
problemDetails.Type,
problemDetails.Detail,
problemDetails.Instance);
}
return new ObjectResult(problemDetails)
{
StatusCode = problemDetails.Status,
};
} If The more important thing is: if we want to incorporate I strongly suggest that our default implementation does not go this route. In MVC, I would only do this if we have access to such a factory via a provided I'm currently working on a PR - I hope I can finish it today. |
Hey guys, I created an alternate implementation of this feature, you can see it here: #110 I'm interested in your feedback - I will try to enhance this PR in the upcoming days with more tests. |
just to be clear - the PR was more of a POC to get some feedback, it's still missing many features and safe checks. Thanks for creating the PR, will take a look now! 🫶 |
@amantinband I am really looking forward to using this library and the ASPNet extensions, will they be available soon? |
First of all, I love this extension.
I would like to see extension methods to convert an ErrorOr (domain result) to an IActionResult or IResult.
The conversion should send 200OK or 204NoContent if the operation is successfull.
If the result is an Error the conversion would take the type of error and return an appropriate ProblemDetails with the correct status Code.
This enhancement was inspired by this repository https://github.com/AKlaus/DomainResult
The text was updated successfully, but these errors were encountered: