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