diff --git a/sample/Sample.Core/Operations/LaunchRecords/DeleteLaunchRecord.cs b/sample/Sample.Core/Operations/LaunchRecords/DeleteLaunchRecord.cs index b835c1828..d587c6a19 100644 --- a/sample/Sample.Core/Operations/LaunchRecords/DeleteLaunchRecord.cs +++ b/sample/Sample.Core/Operations/LaunchRecords/DeleteLaunchRecord.cs @@ -49,6 +49,12 @@ public async Task Handle(Request request, CancellationToken cancellationTo throw new NotFoundException(); } + // contrived for testing + if (rocket.Id == new LaunchRecordId(new Guid("bad361de-a6d5-425a-9cf6-f9b2dd236be6"))) + { + throw new NotAuthorizedException("Unable to operate on given record"); + } + _dbContext.Remove(rocket); await _dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/AspNetCore/Conventions/AspNetCoreConvention.cs b/src/AspNetCore/Conventions/AspNetCoreConvention.cs index e54b15f75..8427985b1 100644 --- a/src/AspNetCore/Conventions/AspNetCoreConvention.cs +++ b/src/AspNetCore/Conventions/AspNetCoreConvention.cs @@ -128,6 +128,7 @@ public void Register(IConventionContext context, IConfiguration configuration, I options => { options.Filters.Add(); + options.Filters.Add(); options.Filters.Add(); options.Filters.Add(0); options.Filters.Add(0); diff --git a/src/AspNetCore/Conventions/FluentValidationConvention.cs b/src/AspNetCore/Conventions/FluentValidationConvention.cs index 4e727517c..56b92d0ef 100644 --- a/src/AspNetCore/Conventions/FluentValidationConvention.cs +++ b/src/AspNetCore/Conventions/FluentValidationConvention.cs @@ -1,4 +1,5 @@ -using System.Reflection; +using System.Net; +using System.Reflection; using FluentValidation; using FluentValidation.AspNetCore; using FluentValidation.Results; @@ -16,7 +17,9 @@ using Rocket.Surgery.Conventions.DependencyInjection; using Rocket.Surgery.Extensions; using Rocket.Surgery.LaunchPad.AspNetCore.Validation; +using Rocket.Surgery.LaunchPad.Foundation; using Rocket.Surgery.LaunchPad.Foundation.Validation; +using ProblemDetailsException = Rocket.Surgery.LaunchPad.Foundation.ProblemDetailsException; namespace Rocket.Surgery.LaunchPad.AspNetCore.Conventions; @@ -125,39 +128,5 @@ public void Register(IConventionContext context, IConfiguration configuration, I .Configure(options => options.JsonSerializerOptions.Converters.Add(new ValidationProblemDetailsConverter())); AddFluentValidationRules(services); - - services.AddOptions() - .Configure>( - (builder, apiBehaviorOptions) => - { - builder.OnBeforeWriteDetails = (_, problemDetails) => - { - if ( - !problemDetails.Status.HasValue - || !apiBehaviorOptions.Value.ClientErrorMapping.TryGetValue(problemDetails.Status.Value, out var clientErrorData) - ) return; - - problemDetails.Title ??= clientErrorData.Title ?? string.Empty; - problemDetails.Type ??= clientErrorData.Link; - }; - builder.Map( - exception => new FluentValidationProblemDetails(exception.Errors) - { - Status = StatusCodes.Status422UnprocessableEntity - } - ); - builder.Map( - (context, _) => context.Items[typeof(ValidationResult)] is ValidationResult, - (context, _) => - { - var result = context.Items[typeof(ValidationResult)] as ValidationResult; - return new FluentValidationProblemDetails(result!.Errors) - { - Status = StatusCodes.Status422UnprocessableEntity - }; - } - ); - } - ); } } diff --git a/src/AspNetCore/Conventions/ProblemDetailsConvention.cs b/src/AspNetCore/Conventions/ProblemDetailsConvention.cs index cd09a97fb..a430d7fa6 100644 --- a/src/AspNetCore/Conventions/ProblemDetailsConvention.cs +++ b/src/AspNetCore/Conventions/ProblemDetailsConvention.cs @@ -1,9 +1,16 @@ -using Hellang.Middleware.ProblemDetails; +using FluentValidation; +using FluentValidation.Results; +using Hellang.Middleware.ProblemDetails; using Hellang.Middleware.ProblemDetails.Mvc; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Rocket.Surgery.Conventions; using Rocket.Surgery.Conventions.DependencyInjection; +using Rocket.Surgery.LaunchPad.AspNetCore.Validation; +using Rocket.Surgery.LaunchPad.Foundation; namespace Rocket.Surgery.LaunchPad.AspNetCore.Conventions; @@ -24,5 +31,48 @@ public void Register(IConventionContext context, IConfiguration configuration, I services .AddProblemDetails() .AddProblemDetailsConventions(); + + services.AddOptions() + .Configure>( + (builder, apiBehaviorOptions) => + { + var currentIncludeExceptionDetails = builder.IncludeExceptionDetails; + builder.IncludeExceptionDetails = (httpContext, exception) => + exception is not IProblemDetailsData && currentIncludeExceptionDetails(httpContext, exception); + builder.OnBeforeWriteDetails = (_, problemDetails) => + { + if ( + !problemDetails.Status.HasValue + || !apiBehaviorOptions.Value.ClientErrorMapping.TryGetValue(problemDetails.Status.Value, out var clientErrorData) + ) + { + return; + } + + problemDetails.Title ??= clientErrorData.Title; + problemDetails.Type ??= clientErrorData.Link; + }; +// builder.MapToProblemDetailsDataException(StatusCodes.Status404NotFound); +// builder.MapToProblemDetailsDataException(StatusCodes.Status400BadRequest); +// builder.MapToProblemDetailsDataException(StatusCodes.Status403Forbidden); + builder.Map( + exception => new FluentValidationProblemDetails(exception.Errors) + { + Status = StatusCodes.Status422UnprocessableEntity + } + ); + builder.Map( + (context, ex) => ex is not IProblemDetailsData && context.Items[typeof(ValidationResult)] is ValidationResult, + (context, _) => + { + var result = context.Items[typeof(ValidationResult)] as ValidationResult; + return new FluentValidationProblemDetails(result!.Errors) + { + Status = StatusCodes.Status422UnprocessableEntity + }; + } + ); + } + ); } } diff --git a/src/AspNetCore/Conventions/ProblemDetailsOptionsExtensions.cs b/src/AspNetCore/Conventions/ProblemDetailsOptionsExtensions.cs new file mode 100644 index 000000000..12f7b1990 --- /dev/null +++ b/src/AspNetCore/Conventions/ProblemDetailsOptionsExtensions.cs @@ -0,0 +1,42 @@ +using Hellang.Middleware.ProblemDetails; +using Microsoft.AspNetCore.Mvc; +using Rocket.Surgery.LaunchPad.Foundation; + +namespace Rocket.Surgery.LaunchPad.AspNetCore.Conventions; + +/// +/// Extensions to ProblemDetailsOptions +/// +public static class ProblemDetailsOptionsExtensions +{ + /// + /// Creates a mapping for an exception that implements with the given status code + /// + /// + /// + /// + public static void MapToProblemDetailsDataException(this ProblemDetailsOptions options, int statusCode) + where TException : Exception, IProblemDetailsData + { + options.Map((_, ex) => ConstructProblemDetails(ex, statusCode)); + } + + private static ProblemDetails ConstructProblemDetails(TException ex, int statusCode) where TException : Exception, IProblemDetailsData + { + var details = new ProblemDetails + { + Detail = ex.Message, + Title = ex.Title, + Type = ex.Link, + Instance = ex.Instance, + Status = statusCode + }; + foreach (var item in ex.Properties) + { + if (details.Extensions.ContainsKey(item.Key)) continue; + details.Extensions.Add(item); + } + + return details; + } +} diff --git a/src/AspNetCore/Filters/NotAuthorizedExceptionFilter.cs b/src/AspNetCore/Filters/NotAuthorizedExceptionFilter.cs new file mode 100644 index 000000000..49944f1df --- /dev/null +++ b/src/AspNetCore/Filters/NotAuthorizedExceptionFilter.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Rocket.Surgery.LaunchPad.Foundation; + +namespace Rocket.Surgery.LaunchPad.AspNetCore.Filters; + +/// +/// Not authorized exception that catches not authorized messages that might have been thrown by calling code. +/// +internal class NotAuthorizedExceptionFilter : ProblemDetailsExceptionFilter +{ + /// + /// Not authorized exception that catches not authorized messages that might have been thrown by calling code. + /// + public NotAuthorizedExceptionFilter(ProblemDetailsFactory problemDetailsFactory) : base(StatusCodes.Status403Forbidden, problemDetailsFactory) + { + } +} diff --git a/src/AspNetCore/Filters/NotFoundExceptionFilter.cs b/src/AspNetCore/Filters/NotFoundExceptionFilter.cs index 2c043e8ca..4b9754ca2 100644 --- a/src/AspNetCore/Filters/NotFoundExceptionFilter.cs +++ b/src/AspNetCore/Filters/NotFoundExceptionFilter.cs @@ -11,51 +11,13 @@ namespace Rocket.Surgery.LaunchPad.AspNetCore.Filters; /// /// Not found exception that catches not found messages that might have been thrown by calling code. /// -internal class NotFoundExceptionFilter : IExceptionFilter, IAsyncExceptionFilter +internal class NotFoundExceptionFilter : ProblemDetailsExceptionFilter { - private readonly ProblemDetailsFactory _problemDetailsFactory; - /// /// Create a new NotFoundExceptionFilter /// /// - public NotFoundExceptionFilter(ProblemDetailsFactory problemDetailsFactory) + public NotFoundExceptionFilter(ProblemDetailsFactory problemDetailsFactory) : base(StatusCodes.Status404NotFound, problemDetailsFactory) { - _problemDetailsFactory = problemDetailsFactory; - } - - /// - public Task OnExceptionAsync(ExceptionContext context) - { - OnException(context); - return Task.CompletedTask; - } - - /// - public void OnException(ExceptionContext context) - { - if (context.Exception is NotFoundException exception) - { - context.ExceptionHandled = true; - var problemDetails = _problemDetailsFactory.CreateProblemDetails( - context.HttpContext, - StatusCodes.Status404NotFound, - detail: exception.Message, - title: exception.Title, - type: exception.Link, - instance: exception.Instance - ); - - foreach (var item in exception.Properties) - { - if (problemDetails.Extensions.ContainsKey(item.Key)) continue; - problemDetails.Extensions.Add(item); - } - - context.Result = new ObjectResult(problemDetails) - { - StatusCode = StatusCodes.Status404NotFound - }; - } } } diff --git a/src/AspNetCore/Filters/ProblemDetailsExceptionFilter.cs b/src/AspNetCore/Filters/ProblemDetailsExceptionFilter.cs new file mode 100644 index 000000000..19c97c263 --- /dev/null +++ b/src/AspNetCore/Filters/ProblemDetailsExceptionFilter.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Rocket.Surgery.LaunchPad.Foundation; + +namespace Rocket.Surgery.LaunchPad.AspNetCore.Filters; + +/// +/// An exception filter that catches the given problem details exception and uses the given status code to return the result +/// +/// +[PublicAPI] +public abstract class ProblemDetailsExceptionFilter : IExceptionFilter, IAsyncExceptionFilter + where T : Exception, IProblemDetailsData +{ + private readonly int _statusCode; + private readonly ProblemDetailsFactory _problemDetailsFactory; + + /// + /// Create the problem details filter + /// + /// + /// + protected ProblemDetailsExceptionFilter(int statusCode, ProblemDetailsFactory problemDetailsFactory) + { + _statusCode = statusCode; + _problemDetailsFactory = problemDetailsFactory; + } + + /// + /// Allows changing the problem details before it is returned + /// + /// + /// + protected virtual ProblemDetails CustomizeProblemDetails(ProblemDetails problemDetails) + { + return problemDetails; + } + + /// + public Task OnExceptionAsync(ExceptionContext context) + { + OnException(context); + return Task.CompletedTask; + } + + /// + public void OnException(ExceptionContext context) + { + if (context.Exception is T exception) + { + context.ExceptionHandled = true; + var problemDetails = _problemDetailsFactory.CreateProblemDetails( + context.HttpContext, + _statusCode, + detail: exception.Message, + title: exception.Title, + type: exception.Link, + instance: exception.Instance + ); + + foreach (var item in exception.Properties) + { + if (problemDetails.Extensions.ContainsKey(item.Key)) continue; + problemDetails.Extensions.Add(item); + } + + problemDetails = CustomizeProblemDetails(problemDetails); + context.Result = new ObjectResult(problemDetails) { StatusCode = _statusCode }; + } + } +} diff --git a/src/AspNetCore/Filters/RequestFailedExceptionFilter.cs b/src/AspNetCore/Filters/RequestFailedExceptionFilter.cs index 888b8a203..f681c1019 100644 --- a/src/AspNetCore/Filters/RequestFailedExceptionFilter.cs +++ b/src/AspNetCore/Filters/RequestFailedExceptionFilter.cs @@ -9,53 +9,14 @@ namespace Rocket.Surgery.LaunchPad.AspNetCore.Filters; /// -/// Not found exception that catches not found messages that might have been thrown by calling code. +/// Request failed exception that catches issues that might have been thrown by calling code. /// -internal class RequestFailedExceptionFilter : IExceptionFilter, IAsyncExceptionFilter +internal class RequestFailedExceptionFilter : ProblemDetailsExceptionFilter { - private readonly ProblemDetailsFactory _problemDetailsFactory; - /// - /// Not found exception that catches not found messages that might have been thrown by calling code. + /// Request failed exception that catches issues that might have been thrown by calling code. /// - public RequestFailedExceptionFilter(ProblemDetailsFactory problemDetailsFactory) + public RequestFailedExceptionFilter(ProblemDetailsFactory problemDetailsFactory) : base(StatusCodes.Status400BadRequest, problemDetailsFactory) { - _problemDetailsFactory = problemDetailsFactory; - } - - /// - public Task OnExceptionAsync(ExceptionContext context) - { - OnException(context); - return Task.CompletedTask; - } - - /// - public void OnException(ExceptionContext context) - { - if (context.Exception is RequestFailedException exception) - { - context.ExceptionHandled = true; - var problemDetails = _problemDetailsFactory.CreateProblemDetails( - context.HttpContext, - StatusCodes.Status400BadRequest, - detail: exception.Message, - title: exception.Title, - type: exception.Link, - instance: exception.Instance - ); - - foreach (var item in exception.Properties) - { - if (problemDetails.Extensions.ContainsKey(item.Key)) - continue; - problemDetails.Extensions.Add(item); - } - - context.Result = new ObjectResult(problemDetails) - { - StatusCode = StatusCodes.Status400BadRequest - }; - } } } diff --git a/src/AspNetCore/Validation/ValidatorInterceptor.cs b/src/AspNetCore/Validation/ValidatorInterceptor.cs index 4362aabb2..267160fbf 100644 --- a/src/AspNetCore/Validation/ValidatorInterceptor.cs +++ b/src/AspNetCore/Validation/ValidatorInterceptor.cs @@ -14,16 +14,18 @@ public IValidationContext BeforeAspNetValidation(ActionContext actionContext, IV public ValidationResult AfterAspNetValidation(ActionContext actionContext, IValidationContext validationContext, ValidationResult result) { - actionContext.HttpContext.Items[typeof(ValidationResult)] = result; if (actionContext.ActionDescriptor.Properties.TryGetValue(typeof(CustomizeValidatorAttribute), out var value) && value is string[] includeProperties) { - return new ValidationResult( + result = new ValidationResult( result.Errors .Join(includeProperties, z => z.PropertyName, z => z, (a, b) => a, StringComparer.OrdinalIgnoreCase) ); } + if (result.IsValid) return result; + + actionContext.HttpContext.Items[typeof(ValidationResult)] = result; return result; } } diff --git a/src/Foundation/NotAuthorizedException.cs b/src/Foundation/NotAuthorizedException.cs new file mode 100644 index 000000000..4be9ccb19 --- /dev/null +++ b/src/Foundation/NotAuthorizedException.cs @@ -0,0 +1,36 @@ +namespace Rocket.Surgery.LaunchPad.Foundation; + +/// +/// NotAuthorizedException. +/// +/// +[PublicAPI] +public class NotAuthorizedException : ProblemDetailsException +{ + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + public NotAuthorizedException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// + /// The exception that is the cause of the current exception, or a null reference (Nothing in Visual Basic) if no inner exception is + /// specified. + /// + public NotAuthorizedException(string message, Exception innerException) : base(message, innerException) + { + } + + /// + /// Initializes a new instance of the class. + /// + public NotAuthorizedException() : base("Not Authorized") + { + } +} diff --git a/src/Foundation/NotFoundException.cs b/src/Foundation/NotFoundException.cs index 6a72462c9..d1f47f3d1 100644 --- a/src/Foundation/NotFoundException.cs +++ b/src/Foundation/NotFoundException.cs @@ -5,7 +5,7 @@ /// /// [PublicAPI] -public class NotFoundException : Exception, IProblemDetailsData +public class NotFoundException : ProblemDetailsException { /// /// Initializes a new instance of the class. @@ -33,24 +33,4 @@ public NotFoundException(string message, Exception innerException) : base(messag public NotFoundException() : base("Not Found") { } - - /// - /// Additional properties - /// - public IDictionary Properties { get; init; } = new Dictionary(StringComparer.Ordinal); - - /// - /// Request title - /// - public string? Title { get; init; } - - /// - /// Request Type - /// - public string? Link { get; init; } - - /// - /// The instance for the request - /// - public string? Instance { get; init; } } diff --git a/src/Foundation/ProblemDetailsException.cs b/src/Foundation/ProblemDetailsException.cs new file mode 100644 index 000000000..9b0d445f5 --- /dev/null +++ b/src/Foundation/ProblemDetailsException.cs @@ -0,0 +1,49 @@ +namespace Rocket.Surgery.LaunchPad.Foundation; + +/// +/// NotFoundException. +/// +/// +[PublicAPI] +public abstract class ProblemDetailsException : Exception, IProblemDetailsData +{ + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + protected ProblemDetailsException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// + /// The exception that is the cause of the current exception, or a null reference (Nothing in Visual Basic) if no inner exception is + /// specified. + /// + protected ProblemDetailsException(string message, Exception innerException) : base(message, innerException) + { + } + + /// + /// Additional properties + /// + public IDictionary Properties { get; init; } = new Dictionary(StringComparer.Ordinal); + + /// + /// Request title + /// + public string? Title { get; init; } + + /// + /// Request Type + /// + public string? Link { get; init; } + + /// + /// The instance for the request + /// + public string? Instance { get; init; } +} diff --git a/src/Foundation/RequestFailedException.cs b/src/Foundation/RequestFailedException.cs index 21f025e77..4c1c0a4c2 100644 --- a/src/Foundation/RequestFailedException.cs +++ b/src/Foundation/RequestFailedException.cs @@ -8,7 +8,7 @@ namespace Rocket.Surgery.LaunchPad.Foundation; /// /// [PublicAPI] -public class RequestFailedException : Exception, IProblemDetailsData +public class RequestFailedException : ProblemDetailsException, IProblemDetailsData { /// /// Initializes a new instance of the class. @@ -36,24 +36,4 @@ public RequestFailedException(string message, Exception innerException) : base(m public RequestFailedException() : this(string.Empty) { } - - /// - /// Additional properties - /// - public IDictionary Properties { get; init; } = new Dictionary(StringComparer.Ordinal); - - /// - /// Request title - /// - public string? Title { get; init; } - - /// - /// Request Type - /// - public string? Link { get; init; } - - /// - /// The instance for the request - /// - public string? Instance { get; init; } } diff --git a/src/Grpc/Conventions/GrpcConvention.cs b/src/Grpc/Conventions/GrpcConvention.cs index 4f4ea6c66..f0f0a5a6f 100644 --- a/src/Grpc/Conventions/GrpcConvention.cs +++ b/src/Grpc/Conventions/GrpcConvention.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Rocket.Surgery.Conventions; using Rocket.Surgery.Conventions.DependencyInjection; +using Rocket.Surgery.LaunchPad.Foundation; using Rocket.Surgery.LaunchPad.Grpc.Conventions; using Rocket.Surgery.LaunchPad.Grpc.Validation; @@ -27,6 +28,7 @@ public void Register(IConventionContext context, IConfiguration configuration, I options => { options.EnableMessageValidation(); + options.Interceptors.Add(); options.Interceptors.Add(); options.Interceptors.Add(); } diff --git a/src/Grpc/NotAuthorizedInterceptor.cs b/src/Grpc/NotAuthorizedInterceptor.cs new file mode 100644 index 000000000..1dcbed828 --- /dev/null +++ b/src/Grpc/NotAuthorizedInterceptor.cs @@ -0,0 +1,11 @@ +using Grpc.Core; +using Rocket.Surgery.LaunchPad.Foundation; + +namespace Rocket.Surgery.LaunchPad.Grpc; + +internal class NotAuthorizedInterceptor : ProblemDetailsInterceptor +{ + public NotAuthorizedInterceptor() : base(StatusCode.PermissionDenied) + { + } +} diff --git a/src/Grpc/NotFoundInterceptor.cs b/src/Grpc/NotFoundInterceptor.cs index 5e1c30618..8dd53afd2 100644 --- a/src/Grpc/NotFoundInterceptor.cs +++ b/src/Grpc/NotFoundInterceptor.cs @@ -1,83 +1,11 @@ using Grpc.Core; -using Grpc.Core.Interceptors; using Rocket.Surgery.LaunchPad.Foundation; namespace Rocket.Surgery.LaunchPad.Grpc; -internal class NotFoundInterceptor : Interceptor +internal class NotFoundInterceptor : ProblemDetailsInterceptor { - private static RpcException CreateException(NotFoundException exception) + public NotFoundInterceptor() : base(StatusCode.NotFound) { - return new RpcException( - new Status(StatusCode.NotFound, exception.Title, exception), - CreateMetadata(exception), - exception.Message - ); - } - - private static Metadata CreateMetadata(NotFoundException exception) - { - var metadata = new Metadata(); - if (exception.Title is { }) - metadata.Add("title", exception.Title); - if (exception.Instance is { }) - metadata.Add("instance", exception.Instance); - if (exception.Link is { }) - metadata.Add("link", exception.Link); - metadata.Add("message", exception.Message); - foreach (var item in exception.Properties) - { - metadata.Add(item.Key, item.Value?.ToString()); - } - - return metadata; - } - - public override async Task UnaryServerHandler( - TRequest request, - ServerCallContext context, - UnaryServerMethod continuation - ) - { - try - { - return await continuation(request, context); - } - catch (NotFoundException exception) - { - throw CreateException(exception); - } - } - - public override AsyncUnaryCall AsyncUnaryCall( - TRequest request, - ClientInterceptorContext context, - AsyncUnaryCallContinuation continuation - ) - { - try - { - return continuation(request, context); - } - catch (NotFoundException exception) - { - throw CreateException(exception); - } - } - - public override TResponse BlockingUnaryCall( - TRequest request, - ClientInterceptorContext context, - BlockingUnaryCallContinuation continuation - ) - { - try - { - return continuation(request, context); - } - catch (NotFoundException exception) - { - throw CreateException(exception); - } } } diff --git a/src/Grpc/ProblemDetailsInterceptor.cs b/src/Grpc/ProblemDetailsInterceptor.cs new file mode 100644 index 000000000..12c6bd5a1 --- /dev/null +++ b/src/Grpc/ProblemDetailsInterceptor.cs @@ -0,0 +1,114 @@ +using System.Text.Json; +using Grpc.Core; +using Grpc.Core.Interceptors; +using Rocket.Surgery.LaunchPad.Foundation; + +namespace Rocket.Surgery.LaunchPad.Grpc; + +/// +/// A shared interceptor for handling problem details exceptions +/// +/// +[PublicAPI] +public abstract class ProblemDetailsInterceptor : Interceptor + where T : Exception, IProblemDetailsData +{ + private static Metadata CreateMetadata(T exception) + { + var metadata = new Metadata(); + if (exception.Title is { }) + metadata.Add("title", exception.Title); + if (exception.Instance is { }) + metadata.Add("instance", exception.Instance); + if (exception.Link is { }) + metadata.Add("link", exception.Link); + metadata.Add("message", exception.Message); + foreach (var item in exception.Properties) + { + metadata.Add(item.Key, item.Value is string s ? s : JsonSerializer.Serialize(item.Value)); + } + + return metadata; + } + + private readonly StatusCode _statusCode; + + /// + /// Create the interceptor with it's status code + /// + /// + protected ProblemDetailsInterceptor(StatusCode statusCode) + { + _statusCode = statusCode; + } + + private RpcException CreateException(T exception) + { + return new RpcException( + new Status(_statusCode, exception.Title ?? exception.Message, exception), + CustomizeMetadata(CreateMetadata(exception)), + exception.Message + ); + } + + /// + /// Allows customizing the metadata before it is returned to the rpc system + /// + /// + /// + protected virtual Metadata CustomizeMetadata(Metadata metadata) + { + return metadata; + } + + /// + public override async Task UnaryServerHandler( + TRequest request, + ServerCallContext context, + UnaryServerMethod continuation + ) + { + try + { + return await continuation(request, context); + } + catch (T exception) + { + throw CreateException(exception); + } + } + + /// + public override AsyncUnaryCall AsyncUnaryCall( + TRequest request, + ClientInterceptorContext context, + AsyncUnaryCallContinuation continuation + ) + { + try + { + return continuation(request, context); + } + catch (T exception) + { + throw CreateException(exception); + } + } + + /// + public override TResponse BlockingUnaryCall( + TRequest request, + ClientInterceptorContext context, + BlockingUnaryCallContinuation continuation + ) + { + try + { + return continuation(request, context); + } + catch (T exception) + { + throw CreateException(exception); + } + } +} diff --git a/src/Grpc/RequestFailedInterceptor.cs b/src/Grpc/RequestFailedInterceptor.cs index 2622cd2b4..287fe979e 100644 --- a/src/Grpc/RequestFailedInterceptor.cs +++ b/src/Grpc/RequestFailedInterceptor.cs @@ -1,83 +1,11 @@ using Grpc.Core; -using Grpc.Core.Interceptors; using Rocket.Surgery.LaunchPad.Foundation; namespace Rocket.Surgery.LaunchPad.Grpc; -internal class RequestFailedInterceptor : Interceptor +internal class RequestFailedInterceptor : ProblemDetailsInterceptor { - private static RpcException CreateException(RequestFailedException exception) + public RequestFailedInterceptor() : base(StatusCode.FailedPrecondition) { - return new RpcException( - new Status(StatusCode.FailedPrecondition, exception.Title, exception), - CreateMetadata(exception), - exception.Message - ); - } - - private static Metadata CreateMetadata(RequestFailedException exception) - { - var metadata = new Metadata(); - if (exception.Title is { }) - metadata.Add("title", exception.Title); - if (exception.Instance is { }) - metadata.Add("instance", exception.Instance); - if (exception.Link is { }) - metadata.Add("link", exception.Link); - metadata.Add("message", exception.Message); - foreach (var item in exception.Properties) - { - metadata.Add(item.Key, item.Value?.ToString()); - } - - return metadata; - } - - public override async Task UnaryServerHandler( - TRequest request, - ServerCallContext context, - UnaryServerMethod continuation - ) - { - try - { - return await continuation(request, context); - } - catch (RequestFailedException exception) - { - throw CreateException(exception); - } - } - - public override AsyncUnaryCall AsyncUnaryCall( - TRequest request, - ClientInterceptorContext context, - AsyncUnaryCallContinuation continuation - ) - { - try - { - return continuation(request, context); - } - catch (RequestFailedException exception) - { - throw CreateException(exception); - } - } - - public override TResponse BlockingUnaryCall( - TRequest request, - ClientInterceptorContext context, - BlockingUnaryCallContinuation continuation - ) - { - try - { - return continuation(request, context); - } - catch (RequestFailedException exception) - { - throw CreateException(exception); - } } } diff --git a/src/HotChocolate/GraphqlErrorFilter.cs b/src/HotChocolate/GraphqlErrorFilter.cs index 34e5a298d..b6ad231b1 100644 --- a/src/HotChocolate/GraphqlErrorFilter.cs +++ b/src/HotChocolate/GraphqlErrorFilter.cs @@ -44,6 +44,11 @@ public IError OnError(IError error) builder.SetCode("NOTFOUND"); } + if (error.Exception is NotAuthorizedException) + { + builder.SetCode("NOTAUTHORIZED"); + } + if (error.Exception is RequestFailedException) { builder.SetCode("FAILED"); diff --git a/test/Sample.Core.Tests/LaunchRecords/GetLaunchRecordTests.cs b/test/Sample.Core.Tests/LaunchRecords/GetLaunchRecordTests.cs index fff3deec8..8a325adab 100644 --- a/test/Sample.Core.Tests/LaunchRecords/GetLaunchRecordTests.cs +++ b/test/Sample.Core.Tests/LaunchRecords/GetLaunchRecordTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using NodaTime; using Rocket.Surgery.DependencyInjection; +using Rocket.Surgery.LaunchPad.Foundation; using Sample.Core.Domain; using Sample.Core.Models; using Sample.Core.Operations.LaunchRecords; @@ -55,6 +56,16 @@ public async Task Should_Get_A_LaunchRecord() response.ScheduledLaunchDate.Should().Be(Instant.FromDateTimeOffset(record.ScheduledLaunchDate)); } + [Fact] + public async Task Should_Not_Get_A_Missing_Launch_Record() + { + Func action = () => ServiceProvider.WithScoped().Invoke( + mediator => mediator.Send(new GetLaunchRecord.Request { Id = LaunchRecordId.New() }) + ); + + await action.Should().ThrowAsync(); + } + public GetLaunchRecordTests(ITestOutputHelper outputHelper) : base(outputHelper, LogLevel.Trace) { } diff --git a/test/Sample.Core.Tests/LaunchRecords/RemoveLaunchRecordsTests.cs b/test/Sample.Core.Tests/LaunchRecords/RemoveLaunchRecordsTests.cs index f82e088e4..c69d0992a 100644 --- a/test/Sample.Core.Tests/LaunchRecords/RemoveLaunchRecordsTests.cs +++ b/test/Sample.Core.Tests/LaunchRecords/RemoveLaunchRecordsTests.cs @@ -3,8 +3,11 @@ using MediatR; using Microsoft.Extensions.Logging; using Rocket.Surgery.DependencyInjection; +using Rocket.Surgery.LaunchPad.Foundation; using Sample.Core.Domain; +using Sample.Core.Models; using Sample.Core.Operations.LaunchRecords; +using Sample.Core.Operations.Rockets; using Xunit; using Xunit.Abstractions; @@ -37,6 +40,32 @@ await ServiceProvider.WithScoped().Invoke( ServiceProvider.WithScoped().Invoke(c => c.LaunchRecords.Should().BeEmpty()); } + [Fact] + public async Task Should_Not_Be_Authorized_On_Given_Record() + { + var id = new LaunchRecordId(new Guid("bad361de-a6d5-425a-9cf6-f9b2dd236be6")); + await ServiceProvider.WithScoped() + .Invoke( + async z => + { + var faker = new RocketFaker(); + var rocket = faker.Generate(); + var record = new LaunchRecordFaker(new[] { rocket }.ToList()).Generate(); + z.Add(rocket); + record.Id = id; + z.Add(record); + + await z.SaveChangesAsync(); + return record.Id; + } + ); + + Func action = () => ServiceProvider.WithScoped().Invoke( + mediator => mediator.Send(new DeleteLaunchRecord.Request { Id = id }) + ); + await action.Should().ThrowAsync(); + } + public RemoveLaunchRecordsTests(ITestOutputHelper outputHelper) : base(outputHelper, LogLevel.Trace) { } diff --git a/test/Sample.Core.Tests/Rockets/GetRocketTests.cs b/test/Sample.Core.Tests/Rockets/GetRocketTests.cs index ced6780a8..7330614e6 100644 --- a/test/Sample.Core.Tests/Rockets/GetRocketTests.cs +++ b/test/Sample.Core.Tests/Rockets/GetRocketTests.cs @@ -3,7 +3,9 @@ using MediatR; using Microsoft.Extensions.Logging; using Rocket.Surgery.DependencyInjection; +using Rocket.Surgery.LaunchPad.Foundation; using Sample.Core.Domain; +using Sample.Core.Models; using Sample.Core.Operations.Rockets; using Xunit; using Xunit.Abstractions; @@ -39,6 +41,16 @@ public async Task Should_Get_A_Rocket() response.Sn.Should().Be("12345678901234"); } + [Fact] + public async Task Should_Not_Get_A_Missing_Rocket() + { + Func action = () => ServiceProvider.WithScoped().Invoke( + mediator => mediator.Send(new GetRocket.Request { Id = RocketId.New() }) + ); + + await action.Should().ThrowAsync(); + } + public GetRocketTests(ITestOutputHelper outputHelper) : base(outputHelper, LogLevel.Trace) { } diff --git a/test/Sample.Core.Tests/Rockets/RemoveRocketsTests.cs b/test/Sample.Core.Tests/Rockets/RemoveRocketsTests.cs index 20e449150..33b97e705 100644 --- a/test/Sample.Core.Tests/Rockets/RemoveRocketsTests.cs +++ b/test/Sample.Core.Tests/Rockets/RemoveRocketsTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using Rocket.Surgery.DependencyInjection; using Sample.Core.Domain; +using Sample.Core.Models; using Sample.Core.Operations.Rockets; using Xunit; using Xunit.Abstractions; diff --git a/test/Sample.Restful.Tests/LaunchRecords/GetLaunchRecordTests.cs b/test/Sample.Restful.Tests/LaunchRecords/GetLaunchRecordTests.cs index ce08749c4..53a334bf6 100644 --- a/test/Sample.Restful.Tests/LaunchRecords/GetLaunchRecordTests.cs +++ b/test/Sample.Restful.Tests/LaunchRecords/GetLaunchRecordTests.cs @@ -54,6 +54,18 @@ public async Task Should_Get_A_LaunchRecord() response.ScheduledLaunchDate.Should().Be(record.ScheduledLaunchDate); } + [Fact] + public async Task Should_Not_Get_A_Missing_Launch_Record() + { + var client = new LaunchRecordClient(Factory.CreateClient()); + + Func action = () => client.GetLaunchRecordAsync(Guid.NewGuid()); + await action.Should().ThrowAsync>() + .Where( + z => z.StatusCode == 404 && z.Result.Status == 404 && z.Result.Title == "Not Found" && z.Result.Type == "https://httpstatuses.com/404" + ); + } + public GetLaunchRecordTests(ITestOutputHelper outputHelper) : base(outputHelper) { } diff --git a/test/Sample.Restful.Tests/LaunchRecords/RemoveLaunchRecordsTests.cs b/test/Sample.Restful.Tests/LaunchRecords/RemoveLaunchRecordsTests.cs index bbc99057b..1940eab7c 100644 --- a/test/Sample.Restful.Tests/LaunchRecords/RemoveLaunchRecordsTests.cs +++ b/test/Sample.Restful.Tests/LaunchRecords/RemoveLaunchRecordsTests.cs @@ -1,8 +1,12 @@ using Bogus; using FluentAssertions; +using MediatR; using Rocket.Surgery.DependencyInjection; +using Rocket.Surgery.LaunchPad.Foundation; using Sample.Core; using Sample.Core.Domain; +using Sample.Core.Models; +using Sample.Core.Operations.LaunchRecords; using Sample.Restful.Client; using Xunit; using Xunit.Abstractions; @@ -35,6 +39,33 @@ public async Task Should_Remove_LaunchRecord() ServiceProvider.WithScoped().Invoke(c => c.LaunchRecords.Should().BeEmpty()); } + [Fact] + public async Task Should_Not_Be_Authorized_On_Given_Record() + { + var id = new LaunchRecordId(new Guid("bad361de-a6d5-425a-9cf6-f9b2dd236be6")); + var client = new LaunchRecordClient(Factory.CreateClient()); + await ServiceProvider.WithScoped() + .Invoke( + async z => + { + var faker = new RocketFaker(); + var rocket = faker.Generate(); + var record = new LaunchRecordFaker(new[] { rocket }.ToList()).Generate(); + z.Add(rocket); + record.Id = id; + z.Add(record); + + await z.SaveChangesAsync(); + return record.Id; + } + ); + + + Func action = () => client.DeleteLaunchRecordAsync(id.Value); + await action.Should().ThrowAsync>() + .Where(z => z.StatusCode == 403 && z.Result.Status == 403 && z.Result.Title == "Forbidden"); + } + public RemoveLaunchRecordsTests(ITestOutputHelper outputHelper) : base(outputHelper) { } diff --git a/test/Sample.Restful.Tests/Rockets/GetRocketTests.cs b/test/Sample.Restful.Tests/Rockets/GetRocketTests.cs index 187e579cb..ec7972358 100644 --- a/test/Sample.Restful.Tests/Rockets/GetRocketTests.cs +++ b/test/Sample.Restful.Tests/Rockets/GetRocketTests.cs @@ -38,6 +38,18 @@ public async Task Should_Get_A_Rocket() response.Result.Sn.Should().Be("12345678901234"); } + [Fact] + public async Task Should_Not_Get_A_Missing_Rocket() + { + var client = new RocketClient(Factory.CreateClient()); + + Func action = () => client.GetRocketAsync(Guid.NewGuid()); + await action.Should().ThrowAsync>() + .Where( + z => z.StatusCode == 404 && z.Result.Status == 404 && z.Result.Title == "Not Found" && z.Result.Type == "https://httpstatuses.com/404" + ); + } + public GetRocketTests(ITestOutputHelper outputHelper) : base(outputHelper) { }