diff --git a/EntityInjector.Route/EntityInjector.Route.csproj b/EntityInjector.Route/EntityInjector.Route.csproj index 82ace3a..dd9eabd 100644 --- a/EntityInjector.Route/EntityInjector.Route.csproj +++ b/EntityInjector.Route/EntityInjector.Route.csproj @@ -3,7 +3,7 @@ true EntityInjector.Route - 1.0.0 + 1.0.1 Devies John Johansson; Erik Jergéus netstandard2.1 @@ -18,8 +18,9 @@ - - + + + diff --git a/EntityInjector.Route/Exceptions/Middleware/DefaultRouteBindingProblemDetailsFactory.cs b/EntityInjector.Route/Exceptions/Middleware/DefaultRouteBindingProblemDetailsFactory.cs new file mode 100644 index 0000000..ec9d452 --- /dev/null +++ b/EntityInjector.Route/Exceptions/Middleware/DefaultRouteBindingProblemDetailsFactory.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace EntityInjector.Route.Exceptions.Middleware; + +public class DefaultRouteBindingProblemDetailsFactory : IRouteBindingProblemDetailsFactory +{ + public ProblemDetails Create(HttpContext context, RouteBindingException exception) + { + return new ProblemDetails + { + Status = exception.StatusCode, + Detail = exception.Message, + Instance = context.Request.Path + }; + } +} \ No newline at end of file diff --git a/EntityInjector.Route/Exceptions/Middleware/IRouteBindingProblemDetailsFactory.cs b/EntityInjector.Route/Exceptions/Middleware/IRouteBindingProblemDetailsFactory.cs new file mode 100644 index 0000000..193067f --- /dev/null +++ b/EntityInjector.Route/Exceptions/Middleware/IRouteBindingProblemDetailsFactory.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace EntityInjector.Route.Exceptions.Middleware; + +public interface IRouteBindingProblemDetailsFactory +{ + ProblemDetails Create(HttpContext context, RouteBindingException exception); +} \ No newline at end of file diff --git a/EntityInjector.Route/Exceptions/Middleware/RouteBindingApplicationBuilderExtensions.cs b/EntityInjector.Route/Exceptions/Middleware/RouteBindingApplicationBuilderExtensions.cs new file mode 100644 index 0000000..d91c2ca --- /dev/null +++ b/EntityInjector.Route/Exceptions/Middleware/RouteBindingApplicationBuilderExtensions.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Builder; + +namespace EntityInjector.Route.Exceptions.Middleware; + +public static class RouteBindingApplicationBuilderExtensions +{ + public static IApplicationBuilder UseRouteBinding(this IApplicationBuilder app) + { + return app.UseMiddleware(); + } +} diff --git a/EntityInjector.Route/Exceptions/Middleware/RouteBindingExceptionMiddleware.cs b/EntityInjector.Route/Exceptions/Middleware/RouteBindingExceptionMiddleware.cs new file mode 100644 index 0000000..c91efb8 --- /dev/null +++ b/EntityInjector.Route/Exceptions/Middleware/RouteBindingExceptionMiddleware.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace EntityInjector.Route.Exceptions.Middleware; + +public class RouteBindingExceptionMiddleware( + RequestDelegate next, + ILogger logger, + IRouteBindingProblemDetailsFactory? problemDetailsFactory = null) +{ + private readonly IRouteBindingProblemDetailsFactory _problemDetailsFactory = problemDetailsFactory ?? new DefaultRouteBindingProblemDetailsFactory(); + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public async Task Invoke(HttpContext context) + { + try + { + await next(context); + } + catch (RouteBindingException ex) + { + logger.LogWarning(ex, "Route binding error: {Message}", ex.Message); + + var problemDetails = _problemDetailsFactory.Create(context, ex); + + context.Response.StatusCode = problemDetails.Status ?? StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/problem+json"; + + var json = JsonSerializer.Serialize(problemDetails, JsonOptions); + await context.Response.WriteAsync(json); + } + } + +} diff --git a/EntityInjector.Route/Exceptions/Middleware/RouteBindingServiceCollectionExtensions.cs b/EntityInjector.Route/Exceptions/Middleware/RouteBindingServiceCollectionExtensions.cs new file mode 100644 index 0000000..95e0f6b --- /dev/null +++ b/EntityInjector.Route/Exceptions/Middleware/RouteBindingServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace EntityInjector.Route.Exceptions.Middleware; + +public static class RouteBindingServiceCollectionExtensions +{ + public static IServiceCollection AddRouteBinding(this IServiceCollection services) + { + // Register default formatter if user hasn't already + services.TryAddSingleton(); + return services; + } +} diff --git a/EntityInjector.Route/Exceptions/StatusExceptions.cs b/EntityInjector.Route/Exceptions/StatusExceptions.cs index 1812341..b91246c 100644 --- a/EntityInjector.Route/Exceptions/StatusExceptions.cs +++ b/EntityInjector.Route/Exceptions/StatusExceptions.cs @@ -1,5 +1,84 @@ +using Microsoft.AspNetCore.Http; + namespace EntityInjector.Route.Exceptions; -public class InternalServerErrorException(string message) : Exception(message); +public abstract class RouteBindingException(string message, Exception? inner = null) + : Exception(message, inner) +{ + public abstract int StatusCode { get; } +} + +public sealed class RouteEntityNotFoundException(string entityName, object? id) + : RouteBindingException($"No {entityName} found for ID '{id}'.") +{ + public override int StatusCode => StatusCodes.Status404NotFound; + + public string EntityName { get; } = entityName; + public object? Id { get; } = id; +} + +public sealed class MissingRouteAttributeException(string parameterName, string expectedAttribute) + : RouteBindingException($"Missing required {expectedAttribute} on action parameter '{parameterName}'.") +{ + public override int StatusCode => StatusCodes.Status400BadRequest; +} + +public sealed class UnsupportedBindingTypeException(Type targetType) + : RouteBindingException($"The type '{targetType.Name}' is not supported for route binding.") +{ + public override int StatusCode => StatusCodes.Status400BadRequest; + + public Type TargetType { get; } = targetType; +} + +public sealed class BindingReceiverNotRegisteredException(Type receiverType) + : RouteBindingException($"No binding receiver registered for type '{receiverType.FullName}'.") +{ + public override int StatusCode => StatusCodes.Status500InternalServerError; + + public Type ReceiverType { get; } = receiverType; +} + +public sealed class BindingReceiverContractException(string methodName, Type receiverType) + : RouteBindingException($"Expected method '{methodName}' not found on receiver type '{receiverType.Name}'.") +{ + public override int StatusCode => StatusCodes.Status500InternalServerError; + + public string MethodName { get; } = methodName; + public Type ReceiverType { get; } = receiverType; +} + +public sealed class UnexpectedBindingResultException(Type expected, Type? actual) + : RouteBindingException($"Expected result of type '{expected.Name}', but got '{actual?.Name ?? "null"}'.") +{ + public override int StatusCode => StatusCodes.Status500InternalServerError; + + public Type ExpectedType { get; } = expected; + public Type? ActualType { get; } = actual; +} + +public sealed class MissingRouteParameterException(string parameterName) + : RouteBindingException($"Route parameter '{parameterName}' was not found. Ensure it is correctly specified in the route.") +{ + public override int StatusCode => StatusCodes.Status400BadRequest; + + public string ParameterName { get; } = parameterName; +} + +public sealed class InvalidRouteParameterFormatException(string parameterName, Type expectedType, Type actualType) + : RouteBindingException($"Route parameter '{parameterName}' is of type '{actualType.Name}', but type '{expectedType.Name}' was expected.") +{ + public override int StatusCode => StatusCodes.Status422UnprocessableEntity; + + public string ParameterName { get; } = parameterName; + public Type ExpectedType { get; } = expectedType; + public Type ActualType { get; } = actualType; +} + +public sealed class EmptyRouteSegmentListException(string parameterName) + : RouteBindingException($"Route parameter '{parameterName}' did not contain any valid string segments.") +{ + public override int StatusCode => StatusCodes.Status422UnprocessableEntity; -public class NotFoundException(string message) : Exception(message); \ No newline at end of file + public string ParameterName { get; } = parameterName; +} diff --git a/EntityInjector.Route/Middleware/BindingMetadata/Collection/GuidCollectionBindingMetadataProvicer.cs b/EntityInjector.Route/Middleware/BindingMetadata/Collection/GuidCollectionBindingMetadataProvicer.cs index 103383a..5a41ee1 100644 --- a/EntityInjector.Route/Middleware/BindingMetadata/Collection/GuidCollectionBindingMetadataProvicer.cs +++ b/EntityInjector.Route/Middleware/BindingMetadata/Collection/GuidCollectionBindingMetadataProvicer.cs @@ -11,28 +11,30 @@ protected override List GetIds(ActionContext context, string argumentName) var routeValue = context.HttpContext.GetRouteValue(argumentName); if (routeValue == null) - throw new InternalServerErrorException( - $"No route value found for parameter '{argumentName}'. Ensure it's included in the route."); + throw new MissingRouteParameterException(argumentName); var rawString = routeValue.ToString(); if (string.IsNullOrWhiteSpace(rawString)) - throw new InternalServerErrorException( - $"Route parameter '{argumentName}' is present but empty. Expected a comma-separated list of GUIDs."); + throw new InvalidRouteParameterFormatException(argumentName, typeof(List), typeof(string)); var segments = rawString.Split(','); - var invalidSegments = new List(); var parsedGuids = new List(); foreach (var segment in segments) + { if (Guid.TryParse(segment, out var parsed)) parsedGuids.Add(parsed); else invalidSegments.Add(segment); + } if (invalidSegments.Any()) - throw new InternalServerErrorException( - $"The following values in route parameter '{argumentName}' are not valid GUIDs: {string.Join(", ", invalidSegments)}."); + throw new InvalidRouteParameterFormatException( + argumentName, + typeof(Guid), + typeof(string) + ); return parsedGuids; } diff --git a/EntityInjector.Route/Middleware/BindingMetadata/Collection/IntCollectionBindingMetadataProvicer.cs b/EntityInjector.Route/Middleware/BindingMetadata/Collection/IntCollectionBindingMetadataProvicer.cs index b03f5e6..6fa9c94 100644 --- a/EntityInjector.Route/Middleware/BindingMetadata/Collection/IntCollectionBindingMetadataProvicer.cs +++ b/EntityInjector.Route/Middleware/BindingMetadata/Collection/IntCollectionBindingMetadataProvicer.cs @@ -11,28 +11,30 @@ protected override List GetIds(ActionContext context, string argumentName) var routeValue = context.HttpContext.GetRouteValue(argumentName); if (routeValue == null) - throw new InternalServerErrorException( - $"No route value found for parameter '{argumentName}'. Ensure it's included in the route."); + throw new MissingRouteParameterException(argumentName); var rawString = routeValue.ToString(); if (string.IsNullOrWhiteSpace(rawString)) - throw new InternalServerErrorException( - $"Route parameter '{argumentName}' is present but empty. Expected a comma-separated list of ints."); + throw new InvalidRouteParameterFormatException(argumentName, typeof(List), typeof(string)); var segments = rawString.Split(','); - var invalidSegments = new List(); var parsedInts = new List(); foreach (var segment in segments) + { if (int.TryParse(segment, out var parsed)) parsedInts.Add(parsed); else invalidSegments.Add(segment); + } if (invalidSegments.Any()) - throw new InternalServerErrorException( - $"The following values in route parameter '{argumentName}' are not valid ints: {string.Join(", ", invalidSegments)}."); + throw new InvalidRouteParameterFormatException( + argumentName, + typeof(int), + typeof(string) + ); return parsedInts; } diff --git a/EntityInjector.Route/Middleware/BindingMetadata/Collection/StringCollectionBindingMetadataProvicer.cs b/EntityInjector.Route/Middleware/BindingMetadata/Collection/StringCollectionBindingMetadataProvicer.cs index e53dbec..0aff6ed 100644 --- a/EntityInjector.Route/Middleware/BindingMetadata/Collection/StringCollectionBindingMetadataProvicer.cs +++ b/EntityInjector.Route/Middleware/BindingMetadata/Collection/StringCollectionBindingMetadataProvicer.cs @@ -4,21 +4,18 @@ namespace EntityInjector.Route.Middleware.BindingMetadata.Collection; -public class - StringCollectionBindingMetadataProvider : FromRouteToCollectionBindingMetadataProvider +public class StringCollectionBindingMetadataProvider : FromRouteToCollectionBindingMetadataProvider { protected override List GetIds(ActionContext context, string argumentName) { var routeValue = context.HttpContext.GetRouteValue(argumentName); if (routeValue == null) - throw new InternalServerErrorException( - $"No route value found for parameter '{argumentName}'. Ensure it's included in the route."); + throw new MissingRouteParameterException(argumentName); var rawString = routeValue.ToString(); if (string.IsNullOrWhiteSpace(rawString)) - throw new InternalServerErrorException( - $"Route parameter '{argumentName}' is present but empty. Expected a comma-separated list of GUIDs."); + throw new InvalidRouteParameterFormatException(argumentName, typeof(string), routeValue.GetType()); var segments = rawString .Split(',', StringSplitOptions.RemoveEmptyEntries) @@ -27,8 +24,7 @@ protected override List GetIds(ActionContext context, string argumentNam .ToList(); if (segments.Count == 0) - throw new InternalServerErrorException( - $"Route parameter '{argumentName}' did not contain any valid string segments."); + throw new EmptyRouteSegmentListException(argumentName); return segments; } diff --git a/EntityInjector.Route/Middleware/BindingMetadata/Entity/GuidEntityBindingMetadataProvicer.cs b/EntityInjector.Route/Middleware/BindingMetadata/Entity/GuidEntityBindingMetadataProvicer.cs index 60eee53..f274c6d 100644 --- a/EntityInjector.Route/Middleware/BindingMetadata/Entity/GuidEntityBindingMetadataProvicer.cs +++ b/EntityInjector.Route/Middleware/BindingMetadata/Entity/GuidEntityBindingMetadataProvicer.cs @@ -11,23 +11,13 @@ protected override Guid GetId(ActionContext context, string argumentName) var routeValue = context.HttpContext.GetRouteValue(argumentName); if (routeValue == null) - throw new InternalServerErrorException( - $"Route value for '{argumentName}' was not found. Make sure it is part of the route pattern."); + throw new MissingRouteParameterException(argumentName); - try + return routeValue switch { - return routeValue switch - { - Guid g => g, - string s when Guid.TryParse(s, out var parsed) => parsed, - _ => throw new InvalidCastException( - $"Route value '{argumentName}' is neither a GUID nor a string that can be parsed into a GUID.") - }; - } - catch (Exception) - { - throw new InternalServerErrorException( - $"Failed to parse route value '{argumentName}' as a GUID. Value: '{routeValue}'"); - } + Guid g => g, + string s when Guid.TryParse(s, out var parsed) => parsed, + _ => throw new InvalidRouteParameterFormatException(argumentName, typeof(Guid), routeValue.GetType()) + }; } } \ No newline at end of file diff --git a/EntityInjector.Route/Middleware/BindingMetadata/Entity/IntEntityBindingMetadataProvicer.cs b/EntityInjector.Route/Middleware/BindingMetadata/Entity/IntEntityBindingMetadataProvicer.cs index 8e34e4c..e60693b 100644 --- a/EntityInjector.Route/Middleware/BindingMetadata/Entity/IntEntityBindingMetadataProvicer.cs +++ b/EntityInjector.Route/Middleware/BindingMetadata/Entity/IntEntityBindingMetadataProvicer.cs @@ -11,15 +11,13 @@ protected override int GetId(ActionContext context, string argumentName) var routeValue = context.HttpContext.GetRouteValue(argumentName); if (routeValue == null) - throw new InternalServerErrorException( - $"Route parameter '{argumentName}' was not found. Ensure it is correctly specified in the route."); + throw new MissingRouteParameterException(argumentName); return routeValue switch { int g => g, string s => int.Parse(s), - _ => throw new InternalServerErrorException( - $"Route parameter '{argumentName}' must be a non-empty string or an int, but received type '{routeValue.GetType().Name}'.") + _ => throw new InvalidRouteParameterFormatException(argumentName, routeValue.GetType(), routeValue.GetType()) }; } } \ No newline at end of file diff --git a/EntityInjector.Route/Middleware/BindingMetadata/Entity/StringEntityBindingMetadataProvicer.cs b/EntityInjector.Route/Middleware/BindingMetadata/Entity/StringEntityBindingMetadataProvicer.cs index 41f014f..8e8061b 100644 --- a/EntityInjector.Route/Middleware/BindingMetadata/Entity/StringEntityBindingMetadataProvicer.cs +++ b/EntityInjector.Route/Middleware/BindingMetadata/Entity/StringEntityBindingMetadataProvicer.cs @@ -11,15 +11,13 @@ protected override string GetId(ActionContext context, string argumentName) var routeValue = context.HttpContext.GetRouteValue(argumentName); if (routeValue == null) - throw new InternalServerErrorException( - $"Route parameter '{argumentName}' was not found. Ensure it is correctly specified in the route."); + throw new MissingRouteParameterException(argumentName); return routeValue switch { string s when !string.IsNullOrWhiteSpace(s) => s, Guid g => g.ToString(), - _ => throw new InternalServerErrorException( - $"Route parameter '{argumentName}' must be a non-empty string or a Guid, but received type '{routeValue.GetType().Name}'.") + _ => throw new InvalidRouteParameterFormatException(argumentName, routeValue.GetType(), routeValue.GetType()) }; } } \ No newline at end of file diff --git a/EntityInjector.Route/Middleware/BindingMetadata/FromRouteToCollectionBindingMetadataProvider.cs b/EntityInjector.Route/Middleware/BindingMetadata/FromRouteToCollectionBindingMetadataProvider.cs index 769bbc9..cbc45c3 100644 --- a/EntityInjector.Route/Middleware/BindingMetadata/FromRouteToCollectionBindingMetadataProvider.cs +++ b/EntityInjector.Route/Middleware/BindingMetadata/FromRouteToCollectionBindingMetadataProvider.cs @@ -7,8 +7,7 @@ namespace EntityInjector.Route.Middleware.BindingMetadata; -public abstract class FromRouteToCollectionBindingMetadataProvider : IBindingMetadataProvider, - IModelBinder +public abstract class FromRouteToCollectionBindingMetadataProvider : IBindingMetadataProvider, IModelBinder where TKey : IComparable { public void CreateBindingMetadata(BindingMetadataProviderContext context) @@ -17,6 +16,7 @@ public void CreateBindingMetadata(BindingMetadataProviderContext context) if (attributes.Count == 0) return; var targetType = context.Key.ModelType; + // Skip configuration if no binding has been created for TValue if (!SupportsType(targetType)) return; @@ -27,14 +27,12 @@ public void CreateBindingMetadata(BindingMetadataProviderContext context) public async Task BindModelAsync(ModelBindingContext bindingContext) { if (bindingContext.ModelMetadata is not DefaultModelMetadata metadata) - throw new InternalServerErrorException( - $"{nameof(bindingContext.ModelMetadata)} is not {nameof(DefaultModelMetadata)}"); + throw new UnexpectedBindingResultException(typeof(DefaultModelMetadata), bindingContext.ModelMetadata?.GetType()); var attribute = metadata.Attributes.ParameterAttributes?.OfType() .FirstOrDefault(); if (attribute == null) - throw new InternalServerErrorException( - $"Missing {nameof(FromRouteToCollectionAttribute)} on action parameter."); + throw new MissingRouteAttributeException(bindingContext.FieldName ?? "", nameof(FromRouteToCollectionAttribute)); var modelType = metadata.ElementMetadata?.ModelType ?? metadata.ModelType.GetGenericArguments().First(); var ids = GetIds(bindingContext.ActionContext, attribute.ArgumentName); @@ -59,35 +57,35 @@ protected bool SupportsType(Type modelType) Dictionary metaData) { if (ids == null || ids.Count == 0) - throw new InternalServerErrorException("No IDs provided for batch resolution."); + throw new UnexpectedBindingResultException(typeof(List), null); var receiverType = typeof(IBindingModelDataReceiver<,>).MakeGenericType(typeof(TKey), dataType); var receiver = context.HttpContext.RequestServices.GetService(receiverType); if (receiver == null) - throw new InternalServerErrorException($"No receiver registered for type {receiverType.Name}"); + throw new BindingReceiverNotRegisteredException(receiverType); var method = receiver.GetType().GetMethod(nameof(IBindingModelDataReceiver.GetByKeys)); if (method == null) - throw new InternalServerErrorException( - $"Method '{nameof(IBindingModelDataReceiver.GetByKeys)}' not found on {receiver.GetType().Name}"); + throw new BindingReceiverContractException(nameof(IBindingModelDataReceiver.GetByKeys), receiver.GetType()); var parameters = new object?[] { ids, context.HttpContext, metaData }; var taskObj = method.Invoke(receiver, parameters); if (taskObj is not Task task) - throw new InternalServerErrorException("Expected a Task return type from GetByKeys"); + throw new UnexpectedBindingResultException(typeof(Task), taskObj?.GetType()); await task; var resultProperty = task.GetType().GetProperty("Result"); - if (resultProperty == null) throw new InternalServerErrorException("Result property missing on resolved Task"); + if (resultProperty == null) + throw new UnexpectedBindingResultException(typeof(Dictionary), null); - if (resultProperty.GetValue(task) is not Dictionary result) - throw new InternalServerErrorException("Result was not of expected Dictionary type."); + var value = resultProperty.GetValue(task); + if (value is not Dictionary result) + throw new UnexpectedBindingResultException(typeof(Dictionary), value?.GetType()); return result; } - protected abstract List GetIds(ActionContext context, string argumentName); -} \ No newline at end of file +} diff --git a/EntityInjector.Route/Middleware/BindingMetadata/FromRouteToEntityBindingMetadataProvider.cs b/EntityInjector.Route/Middleware/BindingMetadata/FromRouteToEntityBindingMetadataProvider.cs index 839f869..eaf1006 100644 --- a/EntityInjector.Route/Middleware/BindingMetadata/FromRouteToEntityBindingMetadataProvider.cs +++ b/EntityInjector.Route/Middleware/BindingMetadata/FromRouteToEntityBindingMetadataProvider.cs @@ -18,10 +18,10 @@ public void CreateBindingMetadata(BindingMetadataProviderContext context) if (fromRouteParameterAttributes.Count == 0) return; var targetType = context.Key.ModelType; + // Skip configuration if no binding has been created for TValue if (!SupportsType(targetType)) return; - context.BindingMetadata.BindingSource = BindingSource.Custom; context.BindingMetadata.BinderType = GetType(); } @@ -29,20 +29,18 @@ public void CreateBindingMetadata(BindingMetadataProviderContext context) public async Task BindModelAsync(ModelBindingContext bindingContext) { if (bindingContext.ModelMetadata is not DefaultModelMetadata metadata) - throw new InternalServerErrorException( - $"{nameof(bindingContext.ModelMetadata)} is not {nameof(DefaultModelMetadata)}"); + throw new UnexpectedBindingResultException(typeof(DefaultModelMetadata), bindingContext.ModelMetadata?.GetType()); var attribute = metadata.Attributes.ParameterAttributes?.OfType().FirstOrDefault(); if (attribute == null) - throw new InternalServerErrorException( - $"Missing {nameof(FromRouteToEntityAttribute)} on action parameter."); + throw new MissingRouteAttributeException(bindingContext.FieldName ?? "unknown", nameof(FromRouteToEntityAttribute)); var id = GetId(bindingContext.ActionContext, attribute.ArgumentName); var dataType = metadata.ModelType; var entity = await GetEntityAsync(id, bindingContext.ActionContext, dataType, attribute.MetaData); - if (entity == null) - throw new NotFoundException($"Route value '{attribute.ArgumentName}' - No {dataType.Name} with Id: {id}"); + if (entity is null) + throw new RouteEntityNotFoundException(dataType.Name, id); bindingContext.Result = ModelBindingResult.Success(entity); } @@ -60,30 +58,32 @@ protected bool SupportsType(Type modelType) var receiverType = typeof(IBindingModelDataReceiver<,>).MakeGenericType(typeof(TKey), dataType); var receiver = context.HttpContext.RequestServices.GetService(receiverType); if (receiver == null) - throw new InternalServerErrorException($"No receiver registered for type {receiverType.Name}"); + throw new BindingReceiverNotRegisteredException(receiverType); var method = receiver.GetType().GetMethod(nameof(IBindingModelDataReceiver.GetByKey)); if (method == null) - throw new InternalServerErrorException( - $"Method '{nameof(IBindingModelDataReceiver.GetByKey)}' not found on {receiver.GetType().Name}"); + throw new BindingReceiverContractException(nameof(IBindingModelDataReceiver.GetByKey), receiver.GetType()); var parameters = new object?[] { id, context.HttpContext, metaData }; var taskObj = method.Invoke(receiver, parameters); if (taskObj is not Task task) - throw new InternalServerErrorException("Expected a Task return type from GetByKey"); + throw new UnexpectedBindingResultException(typeof(Task), taskObj?.GetType()); await task; var resultProperty = task.GetType().GetProperty("Result"); - if (resultProperty == null) throw new InternalServerErrorException("Result property missing on resolved Task"); + if (resultProperty == null) + throw new UnexpectedBindingResultException(typeof(object), null); var result = resultProperty.GetValue(task); + if (result is null) + return default; + if (result is not TValue typedResult) - throw new InternalServerErrorException( - $"Expected result of type {typeof(TValue).Name}, but got {result?.GetType().Name ?? "null"}"); + throw new UnexpectedBindingResultException(typeof(TValue), result?.GetType()); return typedResult; } -} \ No newline at end of file +} diff --git a/EntityInjector.Route/README.md b/EntityInjector.Route/README.md index d29864a..5978700 100644 --- a/EntityInjector.Route/README.md +++ b/EntityInjector.Route/README.md @@ -28,20 +28,39 @@ services.AddScoped, GuidUserDataReceiver>( options.ModelMetadataDetailsProviders.Add(new GuidEntityBindingMetadataProvider()); ``` -## Samples +3. (Optionally) Configure exception handling: + +```csharp +services.AddRouteBinding(); +... +app.UseRouteBinding(); +``` + +You may also opt to configure your own `ProblemDetailsFactory` to customize your exception logic (to for example hide error messages). +In such a case avoid `app.UseRouteBinding()` and instead add your own: +```csharp +services.TryAddSingleton(); +``` -See the Postgres sample using Entity Framework Core and TestContainers: -https://github.com/devies-ab/EntityInjector.Route/tree/main/EntityInjector.Samples.PostgresTest +An example of this can be found in the `CustomFactoryExceptionTests` -The sample demonstrates: +## Samples +See the Sample projects for demonstration on how to: + +- Configure a Postgres database with TestContainers +- Configure a Cosmos database - Fetching single and multiple entities - Configuring different entity keys +- Enabling exception management +- Configuring custom exception management ## Extensibility You can extend `FromRouteToEntityBindingMetadataProvider` or `FromRouteToCollectionBindingMetadataProvider` to support custom key types beyond what is included. +You can also configure your own exception management as described earlier. + ## Limitations Only one key type is supported per entity type to avoid ambiguity during binding. diff --git a/EntityInjector.Samples.CosmosTest/EntityInjector.Samples.CosmosTest.csproj b/EntityInjector.Samples.CosmosTest/EntityInjector.Samples.CosmosTest.csproj index e1c0b28..ff63e5d 100644 --- a/EntityInjector.Samples.CosmosTest/EntityInjector.Samples.CosmosTest.csproj +++ b/EntityInjector.Samples.CosmosTest/EntityInjector.Samples.CosmosTest.csproj @@ -9,13 +9,16 @@ - + - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/EntityInjector.Samples.CosmosTest/Tests/MultipleModelsTests.cs b/EntityInjector.Samples.CosmosTest/Tests/MultipleModelsTests.cs index b53dc09..b5501cb 100644 --- a/EntityInjector.Samples.CosmosTest/Tests/MultipleModelsTests.cs +++ b/EntityInjector.Samples.CosmosTest/Tests/MultipleModelsTests.cs @@ -21,7 +21,11 @@ public class CosmosMultipleModelsTests : IClassFixture { private readonly HttpClient _client; private readonly CosmosTestFixture _fixture; - + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + public CosmosMultipleModelsTests(CosmosTestFixture fixture) { var builder = new WebHostBuilder() @@ -95,7 +99,7 @@ public async Task CanBindFromRouteToUserEntityViaGuid() response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + var result = JsonSerializer.Deserialize(json, _jsonOptions); Assert.Equal(expectedUser.Id, result!.Id); Assert.Equal(expectedUser.Name, result.Name); @@ -112,7 +116,7 @@ public async Task CanBindFromRouteToProductEntityViaInt() response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + var result = JsonSerializer.Deserialize(json, _jsonOptions); Assert.Equal(expectedProduct.Id, result!.Id); Assert.Equal(expectedProduct.Name, result.Name); @@ -129,7 +133,7 @@ public async Task CanFetchMultipleUsersByHttpRequest() response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(); - var returnedUsers = JsonSerializer.Deserialize>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + var returnedUsers = JsonSerializer.Deserialize>(json, _jsonOptions); Assert.NotNull(returnedUsers); Assert.Equal(users.Count, returnedUsers!.Count); @@ -153,7 +157,7 @@ public async Task CanFetchMultipleProductsByHttpRequest() response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(); - var returnedProducts = JsonSerializer.Deserialize>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + var returnedProducts = JsonSerializer.Deserialize>(json, _jsonOptions); Assert.NotNull(returnedProducts); Assert.Equal(products.Count, returnedProducts!.Count); diff --git a/EntityInjector.Samples.CosmosTest/Tests/StringKeyTests.cs b/EntityInjector.Samples.CosmosTest/Tests/StringKeyTests.cs index 347ff02..94a6163 100644 --- a/EntityInjector.Samples.CosmosTest/Tests/StringKeyTests.cs +++ b/EntityInjector.Samples.CosmosTest/Tests/StringKeyTests.cs @@ -21,7 +21,11 @@ public class StringKeyTests : IClassFixture { private readonly HttpClient _client; private readonly CosmosTestFixture _fixture; - + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + public StringKeyTests(CosmosTestFixture fixture) { var builder = new WebHostBuilder() @@ -68,10 +72,7 @@ public async Task CanBindFromRouteToUserEntityViaString() httpResponse.EnsureSuccessStatusCode(); var json = await httpResponse.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(json, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); + var result = JsonSerializer.Deserialize(json, _jsonOptions); Assert.Equal(expectedUser.Id, result!.Id); Assert.Equal(expectedUser.Name, result.Name); @@ -97,10 +98,7 @@ public async Task CanFetchMultipleUsersByHttpRequest() httpResponse.EnsureSuccessStatusCode(); var json = await httpResponse.Content.ReadAsStringAsync(); - var returnedUsers = JsonSerializer.Deserialize>(json, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); + var returnedUsers = JsonSerializer.Deserialize>(json, _jsonOptions); Assert.NotNull(returnedUsers); Assert.Equal(users.Count, returnedUsers!.Count); diff --git a/EntityInjector.Samples.PostgresTest/Controllers/InvalidUserController.cs b/EntityInjector.Samples.PostgresTest/Controllers/InvalidUserController.cs new file mode 100644 index 0000000..617463a --- /dev/null +++ b/EntityInjector.Samples.PostgresTest/Controllers/InvalidUserController.cs @@ -0,0 +1,24 @@ +using EntityInjector.Route.Middleware.Attributes; +using EntityInjector.Samples.PostgresTest.Models; +using Microsoft.AspNetCore.Mvc; + +namespace EntityInjector.Samples.PostgresTest.Controllers; + +[ApiController] +[Route("api/invalid/users")] +public class InvalidUserController : ControllerBase +{ + // Used to ensure correct status code when invalid parameter is inserted into id + [HttpGet("{id?}")] + public ActionResult GetMaybe([FromRouteToEntity("id")] User user) + { + return Ok(new { user.Id, user.Name, user.Age }); + } + + // Used to ensure correct status code when invalid parameter is inserted into id + [HttpGet("batch/{ids?}")] + public ActionResult> GetManyMaybe([FromRouteToCollection("ids")] List users) + { + return Ok(users.Select(u => new { u.Id, u.Name, u.Age })); + } +} \ No newline at end of file diff --git a/EntityInjector.Samples.PostgresTest/EntityInjector.Samples.PostgresTest.csproj b/EntityInjector.Samples.PostgresTest/EntityInjector.Samples.PostgresTest.csproj index 686e2a7..dcf0c64 100644 --- a/EntityInjector.Samples.PostgresTest/EntityInjector.Samples.PostgresTest.csproj +++ b/EntityInjector.Samples.PostgresTest/EntityInjector.Samples.PostgresTest.csproj @@ -5,13 +5,16 @@ - + - + - + - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/EntityInjector.Samples.PostgresTest/Tests/CustomFactoryExceptionTests.cs b/EntityInjector.Samples.PostgresTest/Tests/CustomFactoryExceptionTests.cs new file mode 100644 index 0000000..7408861 --- /dev/null +++ b/EntityInjector.Samples.PostgresTest/Tests/CustomFactoryExceptionTests.cs @@ -0,0 +1,141 @@ +using System.Net; +using System.Text.Json; +using EntityInjector.Route.Exceptions; +using EntityInjector.Route.Exceptions.Middleware; +using EntityInjector.Route.Interfaces; +using EntityInjector.Route.Middleware.BindingMetadata.Collection; +using EntityInjector.Route.Middleware.BindingMetadata.Entity; +using EntityInjector.Samples.PostgresTest.DataReceivers; +using EntityInjector.Samples.PostgresTest.Models; +using EntityInjector.Samples.PostgresTest.Setup; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Xunit; + +namespace EntityInjector.Samples.PostgresTest.Tests; + +public class CustomFactoryExceptionTests : IClassFixture +{ + private readonly HttpClient _client; + private readonly PostgresTestFixture _fixture; + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public CustomFactoryExceptionTests(PostgresTestFixture fixture) + { + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddSingleton(fixture.DbContext); + services.TryAddSingleton(); + + // Use only one type of FromRoute bindings per Value type to avoid ambiguous bindings + services.AddScoped, GuidUserDataReceiver>(); + services.AddScoped, IntProductDataReceiver>(); + + services.AddSingleton(); + services.AddControllers(); + + services.PostConfigureAll(options => + { + // Use only one type of FromRoute bindings per Value type to avoid ambiguous bindings + options.ModelMetadataDetailsProviders.Add(new GuidEntityBindingMetadataProvider()); + + options.ModelMetadataDetailsProviders.Add(new IntEntityBindingMetadataProvider()); + + }); + }) + .Configure(app => + { + app.UseRouting(); + app.UseRouteBinding(); + app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); + }); + + var server = new TestServer(builder); + _client = server.CreateClient(); + _fixture = fixture; + } + + // Custom factory which does not include Detail, + // unless the exception is RouteEntityNotFoundException on a Guid with the Entity User + public class CustomRouteBindingProblemDetailsFactory : IRouteBindingProblemDetailsFactory + { + public ProblemDetails Create(HttpContext context, RouteBindingException exception) + { + var problem = new ProblemDetails + { + Status = exception.StatusCode, + Instance = context.Request.Path + }; + + if (exception is RouteEntityNotFoundException { EntityName: "User" }) + { + problem.Detail = exception.Message; + } + + return problem; + } + } + + [Fact] + public async Task InvalidRouteParameterFormatException_HasNoDetail() + { + var requestUri = "/api/invalid/users/not-a-guid"; + + var response = await _client.GetAsync(requestUri); + + var body = await response.Content.ReadAsStringAsync(); + var problem = JsonSerializer.Deserialize(body, _jsonOptions); + + var expected = new InvalidRouteParameterFormatException("id", typeof(Guid), typeof(string)); + + Assert.NotNull(problem); + Assert.Equal(expected.StatusCode, problem!.Status); + Assert.Null(problem.Detail); + } + + [Fact] + public async Task RouteEntityNotFoundException_ForUser_HasDetail() + { + var userId = Guid.NewGuid(); + var requestUri = $"/api/users/{userId}"; + + var response = await _client.GetAsync(requestUri); + + var body = await response.Content.ReadAsStringAsync(); + var problem = JsonSerializer.Deserialize(body, _jsonOptions); + + var expected = new RouteEntityNotFoundException("User", userId); + + Assert.NotNull(problem); + Assert.Equal(expected.StatusCode, problem!.Status); + Assert.NotNull(problem.Detail); + Assert.Equal(expected.Message, problem.Detail); + } + + [Fact] + public async Task RouteEntityNotFoundException_ForProduct_HasNoDetail() + { + var productId = 9999; + var requestUri = $"/api/products/{productId}"; + + var response = await _client.GetAsync(requestUri); + + var body = await response.Content.ReadAsStringAsync(); + var problem = JsonSerializer.Deserialize(body, _jsonOptions); + + var expected = new RouteEntityNotFoundException("Product", productId); + + Assert.NotNull(problem); + Assert.Equal(expected.StatusCode, problem!.Status); + Assert.Null(problem.Detail); + } +} \ No newline at end of file diff --git a/EntityInjector.Samples.PostgresTest/Tests/GuidExceptionTests.cs b/EntityInjector.Samples.PostgresTest/Tests/GuidExceptionTests.cs new file mode 100644 index 0000000..c2a7164 --- /dev/null +++ b/EntityInjector.Samples.PostgresTest/Tests/GuidExceptionTests.cs @@ -0,0 +1,78 @@ +using System.Net; +using System.Text.Json; +using EntityInjector.Route.Exceptions; +using EntityInjector.Route.Exceptions.Middleware; +using EntityInjector.Route.Interfaces; +using EntityInjector.Route.Middleware.BindingMetadata.Collection; +using EntityInjector.Route.Middleware.BindingMetadata.Entity; +using EntityInjector.Samples.PostgresTest.DataReceivers; +using EntityInjector.Samples.PostgresTest.Models; +using EntityInjector.Samples.PostgresTest.Setup; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace EntityInjector.Samples.PostgresTest.Tests; + +public class GuidExceptionTests : IClassFixture +{ + private readonly HttpClient _client; + private readonly PostgresTestFixture _fixture; + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public GuidExceptionTests(PostgresTestFixture fixture) + { + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddSingleton(fixture.DbContext); + services.AddRouteBinding(); + // Use only one type of FromRoute bindings per Value type to avoid ambiguous bindings + services.AddScoped, GuidUserDataReceiver>(); + + services.AddSingleton(); + services.AddControllers(); + + services.PostConfigureAll(options => + { + // Use only one type of FromRoute bindings per Value type to avoid ambiguous bindings + options.ModelMetadataDetailsProviders.Add(new GuidEntityBindingMetadataProvider()); + options.ModelMetadataDetailsProviders.Add(new GuidCollectionBindingMetadataProvider()); + }); + }) + .Configure(app => + { + app.UseRouting(); + app.UseRouteBinding(); + app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); + }); + + var server = new TestServer(builder); + _client = server.CreateClient(); + _fixture = fixture; + } + + [Fact] + public async Task ReturnsBadRequestWhenGuidRouteParameterIsMalformed() + { + var requestUri = "/api/invalid/users/not-a-guid"; + + var response = await _client.GetAsync(requestUri); + + var body = await response.Content.ReadAsStringAsync(); + var problem = JsonSerializer.Deserialize(body, _jsonOptions); + + var expected = new InvalidRouteParameterFormatException("id", typeof(Guid), typeof(string)); + + Assert.NotNull(problem); + Assert.Equal(expected.StatusCode, problem!.Status); + Assert.Contains("id", problem.Detail); + } +} \ No newline at end of file diff --git a/EntityInjector.Samples.PostgresTest/Tests/MultipleModelsTests.cs b/EntityInjector.Samples.PostgresTest/Tests/MultipleModelsTests.cs index 79e0374..8766b45 100644 --- a/EntityInjector.Samples.PostgresTest/Tests/MultipleModelsTests.cs +++ b/EntityInjector.Samples.PostgresTest/Tests/MultipleModelsTests.cs @@ -20,6 +20,10 @@ public class MultipleModelsTests : IClassFixture { private readonly HttpClient _client; private readonly PostgresTestFixture _fixture; + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; public MultipleModelsTests(PostgresTestFixture fixture) { @@ -68,10 +72,7 @@ public async Task CanBindFromRouteToUserEntityViaGuid() response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(json, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); + var result = JsonSerializer.Deserialize(json, _jsonOptions); Assert.Equal(expectedUser.Id, result!.Id); Assert.Equal(expectedUser.Name, result.Name); @@ -90,10 +91,7 @@ public async Task CanBindFromRouteToProductEntityViaInt() response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(json, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); + var result = JsonSerializer.Deserialize(json, _jsonOptions); Assert.Equal(expectedProduct.Id, result!.Id); Assert.Equal(expectedProduct.Name, result.Name); @@ -113,10 +111,7 @@ public async Task CanFetchMultipleUsersByHttpRequest() response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(); - var returnedUsers = JsonSerializer.Deserialize>(json, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); + var returnedUsers = JsonSerializer.Deserialize>(json, _jsonOptions); Assert.NotNull(returnedUsers); Assert.Equal(users.Count, returnedUsers!.Count); @@ -144,10 +139,7 @@ public async Task CanFetchMultipleProductsByHttpRequest() response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(); - var returnedProducts = JsonSerializer.Deserialize>(json, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); + var returnedProducts = JsonSerializer.Deserialize>(json, _jsonOptions); Assert.NotNull(returnedProducts); Assert.Equal(products.Count, returnedProducts!.Count); diff --git a/EntityInjector.Samples.PostgresTest/Tests/StringExceptionTests.cs b/EntityInjector.Samples.PostgresTest/Tests/StringExceptionTests.cs new file mode 100644 index 0000000..5555e6b --- /dev/null +++ b/EntityInjector.Samples.PostgresTest/Tests/StringExceptionTests.cs @@ -0,0 +1,155 @@ +using System.Net; +using System.Text.Json; +using EntityInjector.Route.Exceptions; +using EntityInjector.Route.Exceptions.Middleware; +using EntityInjector.Route.Interfaces; +using EntityInjector.Route.Middleware.BindingMetadata.Collection; +using EntityInjector.Route.Middleware.BindingMetadata.Entity; +using EntityInjector.Samples.PostgresTest.DataReceivers; +using EntityInjector.Samples.PostgresTest.Models; +using EntityInjector.Samples.PostgresTest.Setup; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace EntityInjector.Samples.PostgresTest.Tests; + +public class StringExceptionTests : IClassFixture +{ + private readonly HttpClient _client; + private readonly PostgresTestFixture _fixture; + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public StringExceptionTests(PostgresTestFixture fixture) + { + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddSingleton(fixture.DbContext); + services.AddRouteBinding(); + // Use only one type of FromRoute bindings per Value type to avoid ambiguous bindings + services.AddScoped, StringUserDataReceiver>(); + + services.AddSingleton(); + services.AddControllers(); + + services.PostConfigureAll(options => + { + // Use only one type of FromRoute bindings per Value type to avoid ambiguous bindings + options.ModelMetadataDetailsProviders.Add(new StringEntityBindingMetadataProvider()); + options.ModelMetadataDetailsProviders.Add(new StringCollectionBindingMetadataProvider()); + + // Add Product binding metadata, but omit the receiver on purpose + options.ModelMetadataDetailsProviders.Add(new IntEntityBindingMetadataProvider()); + }); + }) + .Configure(app => + { + app.UseRouting(); + app.UseRouteBinding(); + app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); + }); + + var server = new TestServer(builder); + _client = server.CreateClient(); + _fixture = fixture; + } + + [Fact] + public async Task ReturnsNotFoundForNonexistentUserId() + { + // Arrange: Use a random GUID to ensure it doesn't exist + var nonexistentUserId = Guid.NewGuid().ToString(); + var requestUri = $"/api/users/{nonexistentUserId}"; + + // Act + var response = await _client.GetAsync(requestUri); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + + var body = await response.Content.ReadAsStringAsync(); + var problem = JsonSerializer.Deserialize(body, _jsonOptions); + + var expected = new RouteEntityNotFoundException("User", nonexistentUserId); + + Assert.NotNull(problem); + Assert.Equal(expected.StatusCode, problem!.Status); + Assert.Contains(expected.Message, problem.Detail); + Assert.Equal(requestUri, problem!.Instance); + } + + [Fact] + public async Task ReturnsBadRequestWhenRouteParameterIsMissing() + { + // Arrange + var requestUri = "/api/invalid/users/"; // missing route value + + // Act + var response = await _client.GetAsync(requestUri); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + + var body = await response.Content.ReadAsStringAsync(); + var problem = JsonSerializer.Deserialize(body, _jsonOptions); + + var expected = new MissingRouteParameterException("id"); + + Assert.NotNull(problem); + Assert.Equal(expected.StatusCode, problem!.Status); + Assert.Equal(expected.Message, problem.Detail); + Assert.Equal(requestUri, problem.Instance); + } + + + [Fact] + public async Task ReturnsInternalServerErrorWhenNoReceiverIsRegistered() + { + // Arrange + var requestUri = $"/api/products/{9999}"; + + // Act + var response = await _client.GetAsync(requestUri); + + // Assert + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + + var body = await response.Content.ReadAsStringAsync(); + var problem = JsonSerializer.Deserialize(body, _jsonOptions); + + var expected = new BindingReceiverNotRegisteredException(typeof(IBindingModelDataReceiver)); + + Assert.NotNull(problem); + Assert.Equal(expected.StatusCode, problem!.Status); + Assert.Equal(expected.Message, problem.Detail); + Assert.Equal(requestUri, problem.Instance); + } + + [Fact] + public async Task ReturnsBadRequestWhenRouteCollectionParameterIsEmpty() + { + var requestUri = "/api/invalid/users/batch/,,,"; + + var response = await _client.GetAsync(requestUri); + + var body = await response.Content.ReadAsStringAsync(); + var problem = JsonSerializer.Deserialize(body, _jsonOptions); + + var expected = new EmptyRouteSegmentListException("ids"); + + Assert.NotNull(problem); + Assert.Equal(expected.StatusCode, problem!.Status); + Assert.Equal(expected.Message, problem.Detail); + } +} \ No newline at end of file diff --git a/EntityInjector.Samples.PostgresTest/Tests/StringKeyTests.cs b/EntityInjector.Samples.PostgresTest/Tests/StringKeyTests.cs index 34aecc7..4518a27 100644 --- a/EntityInjector.Samples.PostgresTest/Tests/StringKeyTests.cs +++ b/EntityInjector.Samples.PostgresTest/Tests/StringKeyTests.cs @@ -20,7 +20,11 @@ public class StringKeyTests : IClassFixture { private readonly HttpClient _client; private readonly PostgresTestFixture _fixture; - + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + public StringKeyTests(PostgresTestFixture fixture) { var builder = new WebHostBuilder() @@ -64,10 +68,7 @@ public async Task CanBindFromRouteToUserEntityViaString() response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(json, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); + var result = JsonSerializer.Deserialize(json, _jsonOptions); Assert.Equal(expectedUser.Id, result!.Id); Assert.Equal(expectedUser.Name, result.Name); @@ -88,10 +89,7 @@ public async Task CanFetchMultipleUsersByHttpRequest() response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(); - var returnedUsers = JsonSerializer.Deserialize>(json, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); + var returnedUsers = JsonSerializer.Deserialize>(json, _jsonOptions); Assert.NotNull(returnedUsers); Assert.Equal(users.Count, returnedUsers!.Count); diff --git a/README.md b/README.md index 318d27c..5ed7aeb 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ in a clean and dependency-injected way. ## Samples -We provide samples using Postgres + EF Core + TestContainers. -See: `EntityInjector.Samples.PostgresTest` +We provide extended test samples using Postgres + EF Core + TestContainers and basic samples with a Cosmos emulator. + +See: `EntityInjector.Samples.PostgresTest` and `EntityInjector.Samples.CosmosTest` ## Development @@ -26,6 +27,4 @@ See: `EntityInjector.Samples.PostgresTest` ## Roadmap -- Add Cosmos DB sample -- Extend tests + failure scenarios -- Exception handling +- Fetching data from Property