diff --git a/EntityInjector.Core/EntityInjector.Core.csproj b/EntityInjector.Core/EntityInjector.Core.csproj new file mode 100644 index 0000000..35bf720 --- /dev/null +++ b/EntityInjector.Core/EntityInjector.Core.csproj @@ -0,0 +1,16 @@ + + + + netstandard2.1 + latest + enable + enable + + + + + + + + + diff --git a/EntityInjector.Core/Exceptions/EntityBindingException.cs b/EntityInjector.Core/Exceptions/EntityBindingException.cs new file mode 100644 index 0000000..6674f25 --- /dev/null +++ b/EntityInjector.Core/Exceptions/EntityBindingException.cs @@ -0,0 +1,8 @@ +namespace EntityInjector.Core.Exceptions; + +public abstract class EntityBindingException(string message, Exception? inner = null) + : Exception(message, inner) +{ + public abstract int StatusCode { get; } + public virtual string? Description => Message; +} \ No newline at end of file diff --git a/EntityInjector.Core/Exceptions/IExceptionMetadata.cs b/EntityInjector.Core/Exceptions/IExceptionMetadata.cs new file mode 100644 index 0000000..3645b61 --- /dev/null +++ b/EntityInjector.Core/Exceptions/IExceptionMetadata.cs @@ -0,0 +1,7 @@ +namespace EntityInjector.Core.Exceptions; + +public interface IExceptionMetadata +{ + int StatusCode { get; } + string DefaultDescription { get; } +} \ No newline at end of file diff --git a/EntityInjector.Route/Exceptions/Middleware/DefaultRouteBindingProblemDetailsFactory.cs b/EntityInjector.Core/Exceptions/Middleware/DefaultEntityBindingProblemDetailsFactory.cs similarity index 53% rename from EntityInjector.Route/Exceptions/Middleware/DefaultRouteBindingProblemDetailsFactory.cs rename to EntityInjector.Core/Exceptions/Middleware/DefaultEntityBindingProblemDetailsFactory.cs index ec9d452..3111195 100644 --- a/EntityInjector.Route/Exceptions/Middleware/DefaultRouteBindingProblemDetailsFactory.cs +++ b/EntityInjector.Core/Exceptions/Middleware/DefaultEntityBindingProblemDetailsFactory.cs @@ -1,11 +1,11 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace EntityInjector.Route.Exceptions.Middleware; +namespace EntityInjector.Core.Exceptions.Middleware; -public class DefaultRouteBindingProblemDetailsFactory : IRouteBindingProblemDetailsFactory +public class DefaultEntityBindingProblemDetailsFactory : IEntityBindingProblemDetailsFactory { - public ProblemDetails Create(HttpContext context, RouteBindingException exception) + public ProblemDetails Create(HttpContext context, EntityBindingException exception) { return new ProblemDetails { diff --git a/EntityInjector.Core/Exceptions/Middleware/EntityBindingApplicationBuilderExtensions.cs b/EntityInjector.Core/Exceptions/Middleware/EntityBindingApplicationBuilderExtensions.cs new file mode 100644 index 0000000..11cf499 --- /dev/null +++ b/EntityInjector.Core/Exceptions/Middleware/EntityBindingApplicationBuilderExtensions.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Builder; + +namespace EntityInjector.Core.Exceptions.Middleware; + +public static class EntityBindingApplicationBuilderExtensions +{ + public static IApplicationBuilder UseEntityBinding(this IApplicationBuilder app) + { + return app.UseMiddleware(); + } +} \ No newline at end of file diff --git a/EntityInjector.Route/Exceptions/Middleware/RouteBindingExceptionMiddleware.cs b/EntityInjector.Core/Exceptions/Middleware/EntityBindingExceptionMiddleware.cs similarity index 56% rename from EntityInjector.Route/Exceptions/Middleware/RouteBindingExceptionMiddleware.cs rename to EntityInjector.Core/Exceptions/Middleware/EntityBindingExceptionMiddleware.cs index c91efb8..fe1906e 100644 --- a/EntityInjector.Route/Exceptions/Middleware/RouteBindingExceptionMiddleware.cs +++ b/EntityInjector.Core/Exceptions/Middleware/EntityBindingExceptionMiddleware.cs @@ -2,28 +2,30 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -namespace EntityInjector.Route.Exceptions.Middleware; +namespace EntityInjector.Core.Exceptions.Middleware; -public class RouteBindingExceptionMiddleware( +public class EntityBindingExceptionMiddleware( RequestDelegate next, - ILogger logger, - IRouteBindingProblemDetailsFactory? problemDetailsFactory = null) + ILogger logger, + IEntityBindingProblemDetailsFactory? problemDetailsFactory = null) { - private readonly IRouteBindingProblemDetailsFactory _problemDetailsFactory = problemDetailsFactory ?? new DefaultRouteBindingProblemDetailsFactory(); private static readonly JsonSerializerOptions JsonOptions = new() { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + private readonly IEntityBindingProblemDetailsFactory _problemDetailsFactory = + problemDetailsFactory ?? new DefaultEntityBindingProblemDetailsFactory(); + public async Task Invoke(HttpContext context) { try { await next(context); } - catch (RouteBindingException ex) + catch (EntityBindingException ex) { - logger.LogWarning(ex, "Route binding error: {Message}", ex.Message); + logger.LogWarning(ex, "Entity binding error: {Message}", ex.Message); var problemDetails = _problemDetailsFactory.Create(context, ex); @@ -34,5 +36,4 @@ public async Task Invoke(HttpContext context) await context.Response.WriteAsync(json); } } - -} +} \ No newline at end of file diff --git a/EntityInjector.Core/Exceptions/Middleware/EntityBindingServiceCollectionExtensions.cs b/EntityInjector.Core/Exceptions/Middleware/EntityBindingServiceCollectionExtensions.cs new file mode 100644 index 0000000..6732ff1 --- /dev/null +++ b/EntityInjector.Core/Exceptions/Middleware/EntityBindingServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace EntityInjector.Core.Exceptions.Middleware; + +public static class EntityBindingServiceCollectionExtensions +{ + public static IServiceCollection AddEntityBinding(this IServiceCollection services) + { + // Register default formatter if user hasn't already + services.TryAddSingleton(); + return services; + } +} \ No newline at end of file diff --git a/EntityInjector.Core/Exceptions/Middleware/IEntityBindingProblemDetailsFactory.cs b/EntityInjector.Core/Exceptions/Middleware/IEntityBindingProblemDetailsFactory.cs new file mode 100644 index 0000000..5949e6d --- /dev/null +++ b/EntityInjector.Core/Exceptions/Middleware/IEntityBindingProblemDetailsFactory.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace EntityInjector.Core.Exceptions.Middleware; + +public interface IEntityBindingProblemDetailsFactory +{ + ProblemDetails Create(HttpContext context, EntityBindingException exception); +} \ No newline at end of file diff --git a/EntityInjector.Core/Exceptions/StatusExceptions.cs b/EntityInjector.Core/Exceptions/StatusExceptions.cs new file mode 100644 index 0000000..36b85e3 --- /dev/null +++ b/EntityInjector.Core/Exceptions/StatusExceptions.cs @@ -0,0 +1,85 @@ +using Microsoft.AspNetCore.Http; + +namespace EntityInjector.Core.Exceptions; + +public sealed class EntityNotFoundException(string entityName, object? id) + : EntityBindingException($"No {entityName} found for ID '{id}'."), IExceptionMetadata +{ + public string EntityName { get; } = entityName; + public object? Id { get; } = id; + public override int StatusCode => StatusCodes.Status404NotFound; + public string DefaultDescription => "The requested entity was not found."; +} + +public sealed class MissingEntityAttributeException(string parameterName, string expectedAttribute) + : EntityBindingException($"Missing required {expectedAttribute} on action parameter '{parameterName}'."), + IExceptionMetadata +{ + public override int StatusCode => StatusCodes.Status400BadRequest; + public string DefaultDescription => "A required parameter attribute was missing."; +} + +public sealed class UnsupportedBindingTypeException(Type targetType) + : EntityBindingException($"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) + : EntityBindingException($"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) + : EntityBindingException($"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) + : EntityBindingException($"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 MissingEntityParameterException(string parameterName) + : EntityBindingException( + $"Route parameter '{parameterName}' was not found. Ensure it is correctly specified in the route."), + IExceptionMetadata +{ + public string ParameterName { get; } = parameterName; + public override int StatusCode => StatusCodes.Status400BadRequest; + public string DefaultDescription => "A required route parameter was missing."; +} + +public sealed class InvalidEntityParameterFormatException(string parameterName, Type expectedType, Type actualType) + : EntityBindingException( + $"Route parameter '{parameterName}' is of type '{actualType.Name}', but type '{expectedType.Name}' was expected."), + IExceptionMetadata +{ + public string ParameterName { get; } = parameterName; + public Type ExpectedType { get; } = expectedType; + public Type ActualType { get; } = actualType; + public override int StatusCode => StatusCodes.Status422UnprocessableEntity; + public string DefaultDescription => "A route parameter was not in the expected format."; +} + +public sealed class EmptyEntitySegmentListException(string parameterName) + : EntityBindingException($"Route parameter '{parameterName}' did not contain any valid string segments."), + IExceptionMetadata +{ + public string ParameterName { get; } = parameterName; + public override int StatusCode => StatusCodes.Status422UnprocessableEntity; + public string DefaultDescription => "The route parameter did not contain any valid values."; +} \ No newline at end of file diff --git a/EntityInjector.Route/Middleware/Attributes/MetadataParsingHelper.cs b/EntityInjector.Core/Helpers/MetadataParsingHelper.cs similarity index 87% rename from EntityInjector.Route/Middleware/Attributes/MetadataParsingHelper.cs rename to EntityInjector.Core/Helpers/MetadataParsingHelper.cs index 2e1e133..ef9f5f6 100644 --- a/EntityInjector.Route/Middleware/Attributes/MetadataParsingHelper.cs +++ b/EntityInjector.Core/Helpers/MetadataParsingHelper.cs @@ -1,4 +1,4 @@ -namespace EntityInjector.Route.Middleware.Attributes; +namespace EntityInjector.Core.Helpers; public static class MetadataParsingHelper { diff --git a/EntityInjector.Route/Interfaces/IBindingModelDataReceiver.cs b/EntityInjector.Core/Interfaces/IBindingModelDataReceiver.cs similarity index 88% rename from EntityInjector.Route/Interfaces/IBindingModelDataReceiver.cs rename to EntityInjector.Core/Interfaces/IBindingModelDataReceiver.cs index 1c9f35d..99ed1fd 100644 --- a/EntityInjector.Route/Interfaces/IBindingModelDataReceiver.cs +++ b/EntityInjector.Core/Interfaces/IBindingModelDataReceiver.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Http; -namespace EntityInjector.Route.Interfaces; +namespace EntityInjector.Core.Interfaces; public interface IBindingModelDataReceiver where TKey : notnull { diff --git a/EntityInjector.Property/Attributes/FromPropertyToEntityAttribute.cs b/EntityInjector.Property/Attributes/FromPropertyToEntityAttribute.cs new file mode 100644 index 0000000..dae1f50 --- /dev/null +++ b/EntityInjector.Property/Attributes/FromPropertyToEntityAttribute.cs @@ -0,0 +1,10 @@ +using EntityInjector.Core.Helpers; + +namespace EntityInjector.Property.Attributes; + +[AttributeUsage(AttributeTargets.Property)] +public class FromPropertyToEntityAttribute(string propertyName, string? metaData = null) : Attribute +{ + public readonly Dictionary MetaData = MetadataParsingHelper.ParseMetaData(metaData); + public readonly string PropertyName = propertyName; +} \ No newline at end of file diff --git a/EntityInjector.Property/EntityInjector.Property.csproj b/EntityInjector.Property/EntityInjector.Property.csproj new file mode 100644 index 0000000..7d056d2 --- /dev/null +++ b/EntityInjector.Property/EntityInjector.Property.csproj @@ -0,0 +1,28 @@ + + + + true + EntityInjector.Property + 1.0.0 + Devies + John Johansson; Erik Jergéus + netstandard2.1 + latest + enable + enable + A small library for injecting entities into proerties into Entity Framework contexts. + MIT + https://github.com/DeviesDevelopment/entity-injector + https://github.com/DeviesDevelopment/entity-injector + git + + + + + + + + + + + diff --git a/EntityInjector.Property/Filters/FromPropertyToEntityActionFilter.cs b/EntityInjector.Property/Filters/FromPropertyToEntityActionFilter.cs new file mode 100644 index 0000000..30bd132 --- /dev/null +++ b/EntityInjector.Property/Filters/FromPropertyToEntityActionFilter.cs @@ -0,0 +1,143 @@ +using System.Collections; +using System.Reflection; +using EntityInjector.Core.Exceptions; +using EntityInjector.Core.Interfaces; +using EntityInjector.Property.Helpers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Logging; + +namespace EntityInjector.Property.Filters; + +public abstract class FromPropertyToEntityActionFilter( + IServiceProvider serviceProvider, + ILogger> logger) + : IAsyncActionFilter +{ + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + if (context.ActionArguments == null || context.ActionArguments.Count == 0) + { + await next(); + return; + } + + var toProcess = EntityBindingCollector.Collect(context.ActionArguments.Values, context.ModelState); + + var groupedByType = toProcess + .GroupBy(info => (info.EntityType, + string.Join("&", info.MetaData.OrderBy(x => x.Key).Select(x => $"{x.Key}={x.Value}")))) + .Select(g => new { g.Key.EntityType, g.First().MetaData, Bindings = g.ToList() }) + .ToList(); + + foreach (var group in groupedByType) + { + var allIds = group.Bindings.SelectMany(b => b.Ids).Distinct().ToList(); + if (allIds.Count == 0) continue; + + var fetchedEntities = await GetEntitiesAsync(allIds, context.HttpContext, group.EntityType, group.MetaData); + + foreach (var binding in group.Bindings) + EntityPopulator.Populate( + binding.TargetObject, + binding.TargetProperty, + binding.EntityType, + binding.Ids.Cast().ToList(), + fetchedEntities, + context.ModelState, + binding.MetaData); + } + + await next(); + } + + protected abstract TKey ConvertToKey(object rawValue, string propertyName); + + protected abstract TKey GetDefaultValueForNull(); + + protected virtual TKey GetId(ActionExecutingContext context, PropertyInfo propInfo, object objectData) + { + var idValue = propInfo.GetValue(objectData); + var isMarkedAsNullable = NullableReflectionHelper.IsNullable(propInfo); + + return idValue switch + { + null when isMarkedAsNullable => GetDefaultValueForNull(), + null => throw new MissingEntityParameterException(propInfo.Name), + _ => ConvertToKey(idValue, propInfo.Name) + }; + } + + protected virtual List GetIds(ActionExecutingContext context, PropertyInfo propInfo, IEnumerable listData) + { + var ids = new List(); + var isMarkedAsNullable = NullableReflectionHelper.IsNullable(propInfo); + + foreach (var item in listData) + if (item == null) + { + if (!isMarkedAsNullable) + throw new MissingEntityParameterException(propInfo.Name); + + ids.Add(GetDefaultValueForNull()); + } + else + { + ids.Add(ConvertToKey(item, propInfo.Name)); + } + + return ids; + } + + private async Task GetEntitiesAsync( + List ids, + HttpContext context, + Type dataType, + Dictionary metaData) + { + var receiverType = typeof(IBindingModelDataReceiver<,>).MakeGenericType(typeof(TKey), dataType); + var receiver = serviceProvider.GetService(receiverType); + + if (receiver == null) + { + logger.LogError("No binding receiver registered for type {ReceiverType}", receiverType.FullName); + throw new BindingReceiverNotRegisteredException(receiverType); + } + + var method = receiver.GetType().GetMethod(nameof(IBindingModelDataReceiver.GetByKeys)); + if (method == null) + { + logger.LogError("Expected method GetByKeys not found on receiver type {ReceiverType}", + receiver.GetType().Name); + throw new BindingReceiverContractException(nameof(IBindingModelDataReceiver.GetByKeys), + receiver.GetType()); + } + + var parameters = new object?[] { ids, context, metaData }; + var invokeTask = method.Invoke(receiver, parameters); + + if (invokeTask is not Task task) + { + logger.LogError("GetByKeys method did not return a Task for type {ReceiverType}", receiver.GetType().Name); + throw new UnexpectedBindingResultException(typeof(Task), invokeTask?.GetType()); + } + + await task; + + var resultProperty = task.GetType().GetProperty(nameof(Task.Result)); + if (resultProperty == null) + { + logger.LogError("Result property missing on returned Task<{ReceiverType}>", receiver.GetType().Name); + throw new UnexpectedBindingResultException(typeof(IDictionary), null); + } + + var value = resultProperty.GetValue(task); + if (value is not IDictionary dict) + { + logger.LogError("Expected IDictionary result but got {ActualType}", value?.GetType().Name ?? "null"); + throw new UnexpectedBindingResultException(typeof(IDictionary), value?.GetType()); + } + + return dict; + } +} \ No newline at end of file diff --git a/EntityInjector.Property/Filters/FromPropertyToEntitySchemaFilter.cs b/EntityInjector.Property/Filters/FromPropertyToEntitySchemaFilter.cs new file mode 100644 index 0000000..5eaf8c8 --- /dev/null +++ b/EntityInjector.Property/Filters/FromPropertyToEntitySchemaFilter.cs @@ -0,0 +1,23 @@ +using EntityInjector.Property.Attributes; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace EntityInjector.Property.Filters; + +public class FromPropertyToEntitySchemaFilter : ISchemaFilter +{ + public void Apply(OpenApiSchema schema, SchemaFilterContext context) + { + if (schema?.Properties == null) return; + + var skipProperties = context.Type.GetProperties() + .Where(t => t.GetCustomAttributes(true).OfType().Any()); + + foreach (var skipProperty in skipProperties) + { + var propertyToSkip = schema.Properties.Keys.SingleOrDefault(x => + string.Equals(x, skipProperty.Name, StringComparison.OrdinalIgnoreCase)); + if (propertyToSkip != null) schema.Properties.Remove(propertyToSkip); + } + } +} \ No newline at end of file diff --git a/EntityInjector.Property/Filters/GuidFromPropertyToEntityActionFilter.cs b/EntityInjector.Property/Filters/GuidFromPropertyToEntityActionFilter.cs new file mode 100644 index 0000000..77b0c4e --- /dev/null +++ b/EntityInjector.Property/Filters/GuidFromPropertyToEntityActionFilter.cs @@ -0,0 +1,25 @@ +using EntityInjector.Core.Exceptions; +using Microsoft.Extensions.Logging; + +namespace EntityInjector.Property.Filters; + +public class GuidFromPropertyToEntityActionFilter( + IServiceProvider serviceProvider, + ILogger logger) + : FromPropertyToEntityActionFilter(serviceProvider, logger) +{ + protected override Guid ConvertToKey(object rawValue, string propertyName) + { + return rawValue switch + { + Guid g => g, + string s when Guid.TryParse(s, out var parsed) => parsed, + _ => throw new InvalidEntityParameterFormatException(propertyName, typeof(Guid), rawValue.GetType()) + }; + } + + protected override Guid GetDefaultValueForNull() + { + return Guid.Empty; + } +} \ No newline at end of file diff --git a/EntityInjector.Property/Filters/IntFromPropertyToEntityActionFilter.cs b/EntityInjector.Property/Filters/IntFromPropertyToEntityActionFilter.cs new file mode 100644 index 0000000..3960f0f --- /dev/null +++ b/EntityInjector.Property/Filters/IntFromPropertyToEntityActionFilter.cs @@ -0,0 +1,32 @@ +using EntityInjector.Core.Exceptions; +using Microsoft.Extensions.Logging; + +namespace EntityInjector.Property.Filters; + +public class IntFromPropertyToEntityActionFilter( + IServiceProvider serviceProvider, + ILogger logger) + : FromPropertyToEntityActionFilter(serviceProvider, logger) +{ + protected override int ConvertToKey(object rawValue, string propertyName) + { + return rawValue switch + { + int i => i, + long l and >= int.MinValue and <= int.MaxValue => (int)l, + short s => s, + byte b => b, + string str when int.TryParse(str, out var parsed) => parsed, + double d when d % 1 == 0 && d is >= int.MinValue and <= int.MaxValue => (int)d, + float f when f % 1 == 0 && f is >= int.MinValue and <= int.MaxValue => (int)f, + decimal m when m % 1 == 0 && m is >= int.MinValue and <= int.MaxValue => (int)m, + _ => throw new InvalidEntityParameterFormatException(propertyName, typeof(int), rawValue.GetType()) + }; + } + + + protected override int GetDefaultValueForNull() + { + return 0; + } +} \ No newline at end of file diff --git a/EntityInjector.Property/Filters/StringFromPropertyToEntityActionFilter.cs b/EntityInjector.Property/Filters/StringFromPropertyToEntityActionFilter.cs new file mode 100644 index 0000000..38b688e --- /dev/null +++ b/EntityInjector.Property/Filters/StringFromPropertyToEntityActionFilter.cs @@ -0,0 +1,25 @@ +using EntityInjector.Core.Exceptions; +using Microsoft.Extensions.Logging; + +namespace EntityInjector.Property.Filters; + +public class StringFromPropertyToEntityActionFilter( + IServiceProvider serviceProvider, + ILogger logger) + : FromPropertyToEntityActionFilter(serviceProvider, logger) +{ + protected override string ConvertToKey(object rawValue, string propertyName) + { + return rawValue switch + { + string a => a, + Guid g => g.ToString(), + _ => throw new InvalidEntityParameterFormatException(propertyName, typeof(string), rawValue.GetType()) + }; + } + + protected override string GetDefaultValueForNull() + { + return ""; + } +} \ No newline at end of file diff --git a/EntityInjector.Property/Helpers/EntityBindingCollector.cs b/EntityInjector.Property/Helpers/EntityBindingCollector.cs new file mode 100644 index 0000000..2944ac3 --- /dev/null +++ b/EntityInjector.Property/Helpers/EntityBindingCollector.cs @@ -0,0 +1,133 @@ +using System.Collections; +using System.Reflection; +using EntityInjector.Core.Exceptions; +using EntityInjector.Property.Attributes; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace EntityInjector.Property.Helpers; + +public static class EntityBindingCollector +{ + public static List> Collect(object? root, ModelStateDictionary modelState) + { + var result = new List>(); + Recurse(root, result, modelState); + return result; + } + + private static void Recurse( + object? currentObject, + List> toProcess, + ModelStateDictionary modelState) + { + if (currentObject == null) + return; + + var objType = currentObject.GetType(); + + if (IsSimpleType(objType)) + return; + + switch (currentObject) + { + case IEnumerable enumerable when objType != typeof(string): + { + foreach (var item in enumerable) + Recurse(item, toProcess, modelState); + + return; + } + case IDictionary dict: + { + foreach (var key in dict.Keys) + { + var val = dict[key]; + Recurse(val, toProcess, modelState); + } + + return; + } + } + + foreach (var prop in objType.GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (prop.GetIndexParameters().Length > 0) + continue; + + var attr = prop.GetCustomAttribute(); + if (attr != null) + { + var idProp = objType.GetProperty( + attr.PropertyName, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + if (idProp == null) + throw new MissingEntityAttributeException( + prop.Name, + $"Expected property '{attr.PropertyName}' to exist on '{prop.DeclaringType?.Name}' for attribute on '{prop.Name}"); + + var idValue = idProp.GetValue(currentObject); + var ids = ExtractIds(idValue); + + var entityType = prop.PropertyType.IsGenericType + ? prop.PropertyType.GenericTypeArguments.Last() + : prop.PropertyType; + + toProcess.Add(new EntityBindingInfo + { + TargetObject = currentObject, + TargetProperty = prop, + EntityType = entityType, + Ids = ids, + MetaData = attr.MetaData + }); + } + else + { + var nestedValue = prop.GetValue(currentObject); + Recurse(nestedValue, toProcess, modelState); + } + } + } + + private static List ExtractIds(object? idValue) + { + var result = new List(); + switch (idValue) + { + case null: + break; + case TKey single: + result.Add(single); + break; + case IEnumerable enumerable when !(idValue is string): + { + foreach (var item in enumerable) + if (item is TKey tk) + result.Add(tk); + break; + } + case IDictionary dict: + { + foreach (var key in dict.Keys) + if (key is TKey tk) + result.Add(tk); + break; + } + } + + return result; + } + + private static bool IsSimpleType(Type type) + { + return type.IsPrimitive + || type.IsEnum + || type == typeof(string) + || type == typeof(decimal) + || type == typeof(DateTime) + || type == typeof(DateTimeOffset) + || type == typeof(TimeSpan) + || type == typeof(Guid); + } +} \ No newline at end of file diff --git a/EntityInjector.Property/Helpers/EntityBindingInfo.cs b/EntityInjector.Property/Helpers/EntityBindingInfo.cs new file mode 100644 index 0000000..c8f3f84 --- /dev/null +++ b/EntityInjector.Property/Helpers/EntityBindingInfo.cs @@ -0,0 +1,15 @@ +using System.Reflection; + +namespace EntityInjector.Property.Helpers; + +/// +/// Class for storing info about a single property that needs entity binding. +/// +public class EntityBindingInfo +{ + public object TargetObject { get; set; } = default!; + public PropertyInfo TargetProperty { get; set; } = default!; + public Type EntityType { get; set; } = default!; + public List Ids { get; set; } = new(); + public Dictionary MetaData { get; set; } = new(); +} \ No newline at end of file diff --git a/EntityInjector.Property/Helpers/EntityPopulator.cs b/EntityInjector.Property/Helpers/EntityPopulator.cs new file mode 100644 index 0000000..475562c --- /dev/null +++ b/EntityInjector.Property/Helpers/EntityPopulator.cs @@ -0,0 +1,137 @@ +using System.Collections; +using System.Reflection; +using EntityInjector.Core.Exceptions; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace EntityInjector.Property.Helpers; + +internal static class EntityPopulator +{ + public static void Populate( + object targetObject, + PropertyInfo targetProperty, + Type entityType, + List ids, + IDictionary fetchedEntities, + ModelStateDictionary modelState, + Dictionary metaData) + { + if (ids.Count == 0) return; + + var cleanNoMatch = metaData.ContainsKey("cleanNoMatch"); + var includeNulls = metaData.ContainsKey("includeNulls"); + + if (IsDictionaryType(targetProperty.PropertyType)) + PopulateDictionary(targetObject, targetProperty, ids, fetchedEntities, modelState, cleanNoMatch, + includeNulls); + else if (IsEnumerableButNotString(targetProperty.PropertyType)) + PopulateList(targetObject, targetProperty, ids, fetchedEntities, modelState, cleanNoMatch, includeNulls); + else + PopulateSingle(targetObject, targetProperty, ids[0], fetchedEntities, modelState, cleanNoMatch, + includeNulls); + } + + private static void PopulateDictionary( + object targetObject, + PropertyInfo prop, + List ids, + IDictionary fetchedEntities, + ModelStateDictionary modelState, + bool cleanNoMatch, + bool includeNulls) + { + var dict = prop.GetValue(targetObject) as IDictionary + ?? Activator.CreateInstance(prop.PropertyType) as IDictionary; + + dict?.Clear(); + + foreach (var id in ids) + if (fetchedEntities.Contains(id)) + dict![id!] = fetchedEntities[id!]; + else if (includeNulls) + dict![id!] = null; + else if (!cleanNoMatch) throw new EntityNotFoundException(prop.Name, id); + + prop.SetValue(targetObject, dict); + } + + private static void PopulateList( + object targetObject, + PropertyInfo prop, + List ids, + IDictionary fetchedEntities, + ModelStateDictionary modelState, + bool cleanNoMatch, + bool includeNulls) + { + var matched = new List(); + + foreach (var id in ids) + if (fetchedEntities.Contains(id)) + matched.Add(fetchedEntities[id!]); + else if (includeNulls) + matched.Add(null); + else if (!cleanNoMatch) throw new EntityNotFoundException(prop.Name, id); + + prop.SetValue(targetObject, ConvertListToTargetType(matched, prop.PropertyType)); + } + + private static void PopulateSingle( + object targetObject, + PropertyInfo prop, + object? id, + IDictionary fetchedEntities, + ModelStateDictionary modelState, + bool cleanNoMatch, + bool includeNulls) + { + if (fetchedEntities.Contains(id)) + prop.SetValue(targetObject, fetchedEntities[id!]); + else if (includeNulls) + prop.SetValue(targetObject, null); + else if (!cleanNoMatch) throw new EntityNotFoundException(prop.Name, id); + } + + private static object? ConvertListToTargetType(List values, Type propType) + { + if (propType.IsArray) + { + var elementType = propType.GetElementType()!; + var array = Array.CreateInstance(elementType, values.Count); + for (var i = 0; i < values.Count; i++) + array.SetValue(values[i], i); + return array; + } + + if (propType.IsGenericType && propType.GetGenericTypeDefinition() == typeof(List<>)) + { + var list = (IList)Activator.CreateInstance(propType)!; + foreach (var v in values) list.Add(v); + return list; + } + + if (typeof(IEnumerable).IsAssignableFrom(propType)) + { + var elementType = propType.GetGenericArguments().FirstOrDefault() ?? typeof(object); + var listType = typeof(List<>).MakeGenericType(elementType); + var list = (IList)Activator.CreateInstance(listType)!; + foreach (var v in values) list.Add(v); + return list; + } + + return values; + } + + private static bool IsDictionaryType(Type type) + { + return typeof(IDictionary).IsAssignableFrom(type) || + (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Dictionary<,>)); + } + + private static bool IsEnumerableButNotString(Type type) + { + return type != typeof(string) && + typeof(IEnumerable).IsAssignableFrom(type) && + !typeof(IDictionary).IsAssignableFrom(type); + } +} \ No newline at end of file diff --git a/EntityInjector.Property/Helpers/NullableReflectionHelper.cs b/EntityInjector.Property/Helpers/NullableReflectionHelper.cs new file mode 100644 index 0000000..61da135 --- /dev/null +++ b/EntityInjector.Property/Helpers/NullableReflectionHelper.cs @@ -0,0 +1,40 @@ +using System.Reflection; + +namespace EntityInjector.Property.Helpers; + +internal static class NullableReflectionHelper +{ + private static readonly Type? NullabilityContextType = + Type.GetType("System.Reflection.NullabilityInfoContext, System.Private.CoreLib"); + + private static readonly MethodInfo? CreateMethod = + NullabilityContextType?.GetMethod("Create", [typeof(MemberInfo)]); + + private static readonly PropertyInfo? WriteStateProperty = + Type.GetType("System.Reflection.NullabilityInfo, System.Private.CoreLib")? + .GetProperty("WriteState"); + + private static readonly object? NullableStateNullable = + Enum.GetValues(Type.GetType("System.Reflection.NullabilityState, System.Private.CoreLib")!) + .Cast().FirstOrDefault(x => x.ToString() == "Nullable"); + + public static bool IsNullable(PropertyInfo prop) + { + try + { + if (NullabilityContextType == null || CreateMethod == null || WriteStateProperty == null) + return true; // fallback: assume nullable + + var context = Activator.CreateInstance(NullabilityContextType); + var nullabilityInfo = CreateMethod.Invoke(context, new object[] { prop }); + + var writeState = WriteStateProperty.GetValue(nullabilityInfo); + + return Equals(writeState, NullableStateNullable); + } + catch + { + return true; // fallback: assume nullable + } + } +} \ No newline at end of file diff --git a/EntityInjector.Property/README.md b/EntityInjector.Property/README.md new file mode 100644 index 0000000..84cca8b --- /dev/null +++ b/EntityInjector.Property/README.md @@ -0,0 +1,190 @@ +# EntityInjector.Property + +EntityInjector.Property simplifies resolving related entities from model properties using [FromPropertyToEntity]. +It is designed for enriching incoming request models (e.g., DTOs in POST/PUT) based on identifier properties, enabling +clean validation and service logic. +Usage + +```csharp +public class PetModel +{ + public Guid OwnerId { get; set; } + + [FromPropertyToEntity(nameof(OwnerId))] + public User? Owner { get; set; } +} + +[HttpPost] +public IActionResult Create([FromBody] PetModel model) +{ + // Owner is already resolved and validated + _service.Register(model.Owner, model); + return Ok(); +} +``` + +## Setup + +1. Register a DataReceiver that implements `IBindingModelDataReceiver`. This defines how to fetch + entities based on key properties. + +```csharp +services.AddScoped, GuidUserDataReceiver>(); +``` + +2. Register the corresponding action filter for the key type used (e.g., `Guid`, `string`,`int`): + +```csharp +options.Filters.Add(); +``` + +Each key type requires a separate action filter inheriting from FromPropertyToEntityActionFilter. + +3. (Optional) Configure custom exception behavior. The default implementation throws a RouteBindingException if an + entity cannot be resolved. You can override this by customizing your data receiver or extending the exception + pipeline. + +## Attribute Behavior + +You can control how missing or unmatched entity references are handled using optional metadata flags in the +`[FromPropertyToEntity]` attribute: + +4. (Optional) Configure a swagger filter for the entities: + +```csharp +services.PostConfigureAll(o => +{ + o.SchemaFilter(); +}); +``` + +### cleanNoMatch + +Type: `bool` (as a `string` key in MetaData) + +Purpose: Suppresses model validation errors when a referenced ID does not resolve to an entity. + +Default behavior (when omitted): A model error is added if an ID has no matching entity. + +Usage example: + +```csharp +[FromPropertyToEntity(nameof(LeadIds), "cleanNoMatch=true")] +public List Users { get; set; } +``` + +### includeNulls + +Type: `bool` (as a `string` key in MetaData) + +Purpose: Ensures that for any unmatched or null ID, a null is explicitly added to the target entity, collection or +dictionary. + +Important: + +If both `includeNulls=true` and `cleanNoMatch=true` are specified, includeNulls takes precedence. + +This means that null values will be inserted for unmatched IDs, and no model errors will be added, regardless of +cleanNoMatch. + +Usage example: + +```csharp +[FromPropertyToEntity(nameof(LeadIds), "cleanNoMatch=true", "includeNulls=true")] +public List Users { get; set; } +``` + +## Use Cases for `[FromPropertyToEntity]` + +The `[FromPropertyToEntity]` attribute enables automatic resolution of related entities from foreign key values in +incoming models. This helps simplify controller logic and improve validation, especially in write operations. + +### Entity Validation on Write + +Ensure referenced entities (e.g., User, Product, Organization) exist before performing operations like `POST`, `PUT`, or +`PATCH`. + +```csharp +{ "ownerId": "abc123", "name": "Bella" } +``` + +With `[FromPropertyToEntity(nameof(OwnerId))]`, you can fail early if the referenced user doesn’t exist. + +### Flattened Input Models (Frontend-Friendly) + +Instead of requiring full nested objects, clients can send flat models with only IDs: + +```csharp +{ "productId": 42, "quantity": 3 } +``` + +Your backend gets the resolved Product object ready-to-use. + +### Authorization Checks + +Easily check whether the current user has access to the resolved entity: + +```csharp +if (!UserCanAccess(model.Customer)) return Forbid(); +``` + +No manual repository calls are needed — the entity is already injected. + +### Batch Entity Resolution + +Handle lists of entities efficiently by grouping and resolving foreign keys in bulk — one query per entity type, +regardless of how many items. + +```csharp +[ + { "name": "Bella", "ownerId": "abc123" }, + { "name": "Max", "ownerId": "abc123" } +] +``` + +The owner is only fetched once, improving performance. + +### Recursive Enrichment of Nested Structures + +Supports nested models with entity references: + +```csharp +public class ProjectModel { + public List TeamMembers { get; set; } +} + +public class TeamMemberModel { + public string UserId { get; set; } + + [FromPropertyToEntity(nameof(UserId))] + public User? User { get; set; } +} +``` + +Each TeamMember gets its corresponding User injected. + +### Simplified Service Layer Usage + +Because the full entities are populated on the DTO, service layers can work directly with them: + +```csharp +_petService.RegisterNewPet(dto.Owner, dto.Name, dto.Species); +``` + +No need to manually resolve entities from IDs inside the service. + +### Enhanced Logging and Auditing + +Capture meaningful details (e.g., resolved User.Name) in logs without extra queries. + +## Samples + +See the sample project for demonstrations on: + +* Mapping identifiers to entity references in POST bodies + +* Combining `[FromPropertyToEntity]` with other injection or validation patterns + +* Using different key types (`Guid`, `string`, `int`) with dedicated filters + +* Testing injection logic using TestServer diff --git a/EntityInjector.Route/Middleware/Attributes/FromRouteToCollectionAttribute.cs b/EntityInjector.Route/Attributes/FromRouteToCollectionAttribute.cs similarity index 79% rename from EntityInjector.Route/Middleware/Attributes/FromRouteToCollectionAttribute.cs rename to EntityInjector.Route/Attributes/FromRouteToCollectionAttribute.cs index 52d7efb..b69d161 100644 --- a/EntityInjector.Route/Middleware/Attributes/FromRouteToCollectionAttribute.cs +++ b/EntityInjector.Route/Attributes/FromRouteToCollectionAttribute.cs @@ -1,4 +1,6 @@ -namespace EntityInjector.Route.Middleware.Attributes; +using EntityInjector.Core.Helpers; + +namespace EntityInjector.Route.Attributes; [AttributeUsage(AttributeTargets.Parameter)] public class FromRouteToCollectionAttribute(string argumentName, string? metaData = null) : Attribute diff --git a/EntityInjector.Route/Middleware/Attributes/FromRouteToEntityAttribute.cs b/EntityInjector.Route/Attributes/FromRouteToEntityAttribute.cs similarity index 79% rename from EntityInjector.Route/Middleware/Attributes/FromRouteToEntityAttribute.cs rename to EntityInjector.Route/Attributes/FromRouteToEntityAttribute.cs index 57dde41..0563c88 100644 --- a/EntityInjector.Route/Middleware/Attributes/FromRouteToEntityAttribute.cs +++ b/EntityInjector.Route/Attributes/FromRouteToEntityAttribute.cs @@ -1,4 +1,6 @@ -namespace EntityInjector.Route.Middleware.Attributes; +using EntityInjector.Core.Helpers; + +namespace EntityInjector.Route.Attributes; [AttributeUsage(AttributeTargets.Parameter)] public class FromRouteToEntityAttribute(string argumentName, string? metaData = null) : Attribute diff --git a/EntityInjector.Route/Middleware/BindingMetadata/Collection/GuidCollectionBindingMetadataProvicer.cs b/EntityInjector.Route/BindingMetadata/Collection/GuidCollectionBindingMetadataProvicer.cs similarity index 73% rename from EntityInjector.Route/Middleware/BindingMetadata/Collection/GuidCollectionBindingMetadataProvicer.cs rename to EntityInjector.Route/BindingMetadata/Collection/GuidCollectionBindingMetadataProvicer.cs index 5a41ee1..450c0d4 100644 --- a/EntityInjector.Route/Middleware/BindingMetadata/Collection/GuidCollectionBindingMetadataProvicer.cs +++ b/EntityInjector.Route/BindingMetadata/Collection/GuidCollectionBindingMetadataProvicer.cs @@ -1,8 +1,8 @@ -using EntityInjector.Route.Exceptions; +using EntityInjector.Core.Exceptions; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; -namespace EntityInjector.Route.Middleware.BindingMetadata.Collection; +namespace EntityInjector.Route.BindingMetadata.Collection; public class GuidCollectionBindingMetadataProvider : FromRouteToCollectionBindingMetadataProvider { @@ -11,26 +11,24 @@ protected override List GetIds(ActionContext context, string argumentName) var routeValue = context.HttpContext.GetRouteValue(argumentName); if (routeValue == null) - throw new MissingRouteParameterException(argumentName); + throw new MissingEntityParameterException(argumentName); var rawString = routeValue.ToString(); if (string.IsNullOrWhiteSpace(rawString)) - throw new InvalidRouteParameterFormatException(argumentName, typeof(List), typeof(string)); + throw new InvalidEntityParameterFormatException(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 InvalidRouteParameterFormatException( + throw new InvalidEntityParameterFormatException( argumentName, typeof(Guid), typeof(string) diff --git a/EntityInjector.Route/Middleware/BindingMetadata/Collection/IntCollectionBindingMetadataProvicer.cs b/EntityInjector.Route/BindingMetadata/Collection/IntCollectionBindingMetadataProvicer.cs similarity index 73% rename from EntityInjector.Route/Middleware/BindingMetadata/Collection/IntCollectionBindingMetadataProvicer.cs rename to EntityInjector.Route/BindingMetadata/Collection/IntCollectionBindingMetadataProvicer.cs index 6fa9c94..86116c6 100644 --- a/EntityInjector.Route/Middleware/BindingMetadata/Collection/IntCollectionBindingMetadataProvicer.cs +++ b/EntityInjector.Route/BindingMetadata/Collection/IntCollectionBindingMetadataProvicer.cs @@ -1,8 +1,8 @@ -using EntityInjector.Route.Exceptions; +using EntityInjector.Core.Exceptions; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; -namespace EntityInjector.Route.Middleware.BindingMetadata.Collection; +namespace EntityInjector.Route.BindingMetadata.Collection; public class IntCollectionBindingMetadataProvider : FromRouteToCollectionBindingMetadataProvider { @@ -11,26 +11,24 @@ protected override List GetIds(ActionContext context, string argumentName) var routeValue = context.HttpContext.GetRouteValue(argumentName); if (routeValue == null) - throw new MissingRouteParameterException(argumentName); + throw new MissingEntityParameterException(argumentName); var rawString = routeValue.ToString(); if (string.IsNullOrWhiteSpace(rawString)) - throw new InvalidRouteParameterFormatException(argumentName, typeof(List), typeof(string)); + throw new InvalidEntityParameterFormatException(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 InvalidRouteParameterFormatException( + throw new InvalidEntityParameterFormatException( argumentName, typeof(int), typeof(string) diff --git a/EntityInjector.Route/Middleware/BindingMetadata/Collection/StringCollectionBindingMetadataProvicer.cs b/EntityInjector.Route/BindingMetadata/Collection/StringCollectionBindingMetadataProvicer.cs similarity index 57% rename from EntityInjector.Route/Middleware/BindingMetadata/Collection/StringCollectionBindingMetadataProvicer.cs rename to EntityInjector.Route/BindingMetadata/Collection/StringCollectionBindingMetadataProvicer.cs index 0aff6ed..a4b4ece 100644 --- a/EntityInjector.Route/Middleware/BindingMetadata/Collection/StringCollectionBindingMetadataProvicer.cs +++ b/EntityInjector.Route/BindingMetadata/Collection/StringCollectionBindingMetadataProvicer.cs @@ -1,21 +1,22 @@ -using EntityInjector.Route.Exceptions; +using EntityInjector.Core.Exceptions; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; -namespace EntityInjector.Route.Middleware.BindingMetadata.Collection; +namespace EntityInjector.Route.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 MissingRouteParameterException(argumentName); + throw new MissingEntityParameterException(argumentName); var rawString = routeValue.ToString(); if (string.IsNullOrWhiteSpace(rawString)) - throw new InvalidRouteParameterFormatException(argumentName, typeof(string), routeValue.GetType()); + throw new InvalidEntityParameterFormatException(argumentName, typeof(string), routeValue.GetType()); var segments = rawString .Split(',', StringSplitOptions.RemoveEmptyEntries) @@ -24,7 +25,7 @@ protected override List GetIds(ActionContext context, string argumentNam .ToList(); if (segments.Count == 0) - throw new EmptyRouteSegmentListException(argumentName); + throw new EmptyEntitySegmentListException(argumentName); return segments; } diff --git a/EntityInjector.Route/Middleware/BindingMetadata/Entity/GuidEntityBindingMetadataProvicer.cs b/EntityInjector.Route/BindingMetadata/Entity/GuidEntityBindingMetadataProvicer.cs similarity index 64% rename from EntityInjector.Route/Middleware/BindingMetadata/Entity/GuidEntityBindingMetadataProvicer.cs rename to EntityInjector.Route/BindingMetadata/Entity/GuidEntityBindingMetadataProvicer.cs index f274c6d..231a3f1 100644 --- a/EntityInjector.Route/Middleware/BindingMetadata/Entity/GuidEntityBindingMetadataProvicer.cs +++ b/EntityInjector.Route/BindingMetadata/Entity/GuidEntityBindingMetadataProvicer.cs @@ -1,8 +1,8 @@ -using EntityInjector.Route.Exceptions; +using EntityInjector.Core.Exceptions; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; -namespace EntityInjector.Route.Middleware.BindingMetadata.Entity; +namespace EntityInjector.Route.BindingMetadata.Entity; public class GuidEntityBindingMetadataProvider : FromRouteToEntityBindingMetadataProvider { @@ -11,13 +11,13 @@ protected override Guid GetId(ActionContext context, string argumentName) var routeValue = context.HttpContext.GetRouteValue(argumentName); if (routeValue == null) - throw new MissingRouteParameterException(argumentName); + throw new MissingEntityParameterException(argumentName); return routeValue switch { Guid g => g, string s when Guid.TryParse(s, out var parsed) => parsed, - _ => throw new InvalidRouteParameterFormatException(argumentName, typeof(Guid), routeValue.GetType()) + _ => throw new InvalidEntityParameterFormatException(argumentName, typeof(Guid), routeValue.GetType()) }; } } \ No newline at end of file diff --git a/EntityInjector.Route/Middleware/BindingMetadata/Entity/IntEntityBindingMetadataProvicer.cs b/EntityInjector.Route/BindingMetadata/Entity/IntEntityBindingMetadataProvicer.cs similarity index 62% rename from EntityInjector.Route/Middleware/BindingMetadata/Entity/IntEntityBindingMetadataProvicer.cs rename to EntityInjector.Route/BindingMetadata/Entity/IntEntityBindingMetadataProvicer.cs index e60693b..a27fe5a 100644 --- a/EntityInjector.Route/Middleware/BindingMetadata/Entity/IntEntityBindingMetadataProvicer.cs +++ b/EntityInjector.Route/BindingMetadata/Entity/IntEntityBindingMetadataProvicer.cs @@ -1,8 +1,8 @@ -using EntityInjector.Route.Exceptions; +using EntityInjector.Core.Exceptions; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; -namespace EntityInjector.Route.Middleware.BindingMetadata.Entity; +namespace EntityInjector.Route.BindingMetadata.Entity; public class IntEntityBindingMetadataProvider : FromRouteToEntityBindingMetadataProvider { @@ -11,13 +11,14 @@ protected override int GetId(ActionContext context, string argumentName) var routeValue = context.HttpContext.GetRouteValue(argumentName); if (routeValue == null) - throw new MissingRouteParameterException(argumentName); + throw new MissingEntityParameterException(argumentName); return routeValue switch { int g => g, string s => int.Parse(s), - _ => throw new InvalidRouteParameterFormatException(argumentName, routeValue.GetType(), routeValue.GetType()) + _ => throw new InvalidEntityParameterFormatException(argumentName, routeValue.GetType(), + routeValue.GetType()) }; } } \ No newline at end of file diff --git a/EntityInjector.Route/Middleware/BindingMetadata/Entity/StringEntityBindingMetadataProvicer.cs b/EntityInjector.Route/BindingMetadata/Entity/StringEntityBindingMetadataProvicer.cs similarity index 64% rename from EntityInjector.Route/Middleware/BindingMetadata/Entity/StringEntityBindingMetadataProvicer.cs rename to EntityInjector.Route/BindingMetadata/Entity/StringEntityBindingMetadataProvicer.cs index 8e8061b..bd28ca7 100644 --- a/EntityInjector.Route/Middleware/BindingMetadata/Entity/StringEntityBindingMetadataProvicer.cs +++ b/EntityInjector.Route/BindingMetadata/Entity/StringEntityBindingMetadataProvicer.cs @@ -1,8 +1,8 @@ -using EntityInjector.Route.Exceptions; +using EntityInjector.Core.Exceptions; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; -namespace EntityInjector.Route.Middleware.BindingMetadata.Entity; +namespace EntityInjector.Route.BindingMetadata.Entity; public class StringEntityBindingMetadataProvider : FromRouteToEntityBindingMetadataProvider { @@ -11,13 +11,14 @@ protected override string GetId(ActionContext context, string argumentName) var routeValue = context.HttpContext.GetRouteValue(argumentName); if (routeValue == null) - throw new MissingRouteParameterException(argumentName); + throw new MissingEntityParameterException(argumentName); return routeValue switch { string s when !string.IsNullOrWhiteSpace(s) => s, Guid g => g.ToString(), - _ => throw new InvalidRouteParameterFormatException(argumentName, routeValue.GetType(), routeValue.GetType()) + _ => throw new InvalidEntityParameterFormatException(argumentName, routeValue.GetType(), + routeValue.GetType()) }; } } \ No newline at end of file diff --git a/EntityInjector.Route/Middleware/BindingMetadata/FromRouteToCollectionBindingMetadataProvider.cs b/EntityInjector.Route/BindingMetadata/FromRouteToCollectionBindingMetadataProvider.cs similarity index 86% rename from EntityInjector.Route/Middleware/BindingMetadata/FromRouteToCollectionBindingMetadataProvider.cs rename to EntityInjector.Route/BindingMetadata/FromRouteToCollectionBindingMetadataProvider.cs index cbc45c3..27fd706 100644 --- a/EntityInjector.Route/Middleware/BindingMetadata/FromRouteToCollectionBindingMetadataProvider.cs +++ b/EntityInjector.Route/BindingMetadata/FromRouteToCollectionBindingMetadataProvider.cs @@ -1,13 +1,14 @@ -using EntityInjector.Route.Exceptions; -using EntityInjector.Route.Interfaces; -using EntityInjector.Route.Middleware.Attributes; +using EntityInjector.Core.Exceptions; +using EntityInjector.Core.Interfaces; +using EntityInjector.Route.Attributes; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; -namespace EntityInjector.Route.Middleware.BindingMetadata; +namespace EntityInjector.Route.BindingMetadata; -public abstract class FromRouteToCollectionBindingMetadataProvider : IBindingMetadataProvider, IModelBinder +public abstract class FromRouteToCollectionBindingMetadataProvider : IBindingMetadataProvider, + IModelBinder where TKey : IComparable { public void CreateBindingMetadata(BindingMetadataProviderContext context) @@ -27,12 +28,14 @@ public void CreateBindingMetadata(BindingMetadataProviderContext context) public async Task BindModelAsync(ModelBindingContext bindingContext) { if (bindingContext.ModelMetadata is not DefaultModelMetadata metadata) - throw new UnexpectedBindingResultException(typeof(DefaultModelMetadata), bindingContext.ModelMetadata?.GetType()); + throw new UnexpectedBindingResultException(typeof(DefaultModelMetadata), + bindingContext.ModelMetadata?.GetType()); var attribute = metadata.Attributes.ParameterAttributes?.OfType() .FirstOrDefault(); if (attribute == null) - throw new MissingRouteAttributeException(bindingContext.FieldName ?? "", nameof(FromRouteToCollectionAttribute)); + throw new MissingEntityAttributeException(bindingContext.FieldName ?? "", + nameof(FromRouteToCollectionAttribute)); var modelType = metadata.ElementMetadata?.ModelType ?? metadata.ModelType.GetGenericArguments().First(); var ids = GetIds(bindingContext.ActionContext, attribute.ArgumentName); @@ -66,7 +69,8 @@ protected bool SupportsType(Type modelType) var method = receiver.GetType().GetMethod(nameof(IBindingModelDataReceiver.GetByKeys)); if (method == null) - throw new BindingReceiverContractException(nameof(IBindingModelDataReceiver.GetByKeys), receiver.GetType()); + throw new BindingReceiverContractException(nameof(IBindingModelDataReceiver.GetByKeys), + receiver.GetType()); var parameters = new object?[] { ids, context.HttpContext, metaData }; var taskObj = method.Invoke(receiver, parameters); @@ -88,4 +92,4 @@ protected bool SupportsType(Type modelType) } 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/BindingMetadata/FromRouteToEntityBindingMetadataProvider.cs similarity index 85% rename from EntityInjector.Route/Middleware/BindingMetadata/FromRouteToEntityBindingMetadataProvider.cs rename to EntityInjector.Route/BindingMetadata/FromRouteToEntityBindingMetadataProvider.cs index eaf1006..94b7bfe 100644 --- a/EntityInjector.Route/Middleware/BindingMetadata/FromRouteToEntityBindingMetadataProvider.cs +++ b/EntityInjector.Route/BindingMetadata/FromRouteToEntityBindingMetadataProvider.cs @@ -1,11 +1,11 @@ -using EntityInjector.Route.Exceptions; -using EntityInjector.Route.Interfaces; -using EntityInjector.Route.Middleware.Attributes; +using EntityInjector.Core.Exceptions; +using EntityInjector.Core.Interfaces; +using EntityInjector.Route.Attributes; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; -namespace EntityInjector.Route.Middleware.BindingMetadata; +namespace EntityInjector.Route.BindingMetadata; public abstract class FromRouteToEntityBindingMetadataProvider : IBindingMetadataProvider, IModelBinder where TKey : IComparable @@ -29,18 +29,20 @@ public void CreateBindingMetadata(BindingMetadataProviderContext context) public async Task BindModelAsync(ModelBindingContext bindingContext) { if (bindingContext.ModelMetadata is not DefaultModelMetadata metadata) - throw new UnexpectedBindingResultException(typeof(DefaultModelMetadata), bindingContext.ModelMetadata?.GetType()); + throw new UnexpectedBindingResultException(typeof(DefaultModelMetadata), + bindingContext.ModelMetadata?.GetType()); var attribute = metadata.Attributes.ParameterAttributes?.OfType().FirstOrDefault(); if (attribute == null) - throw new MissingRouteAttributeException(bindingContext.FieldName ?? "unknown", nameof(FromRouteToEntityAttribute)); + throw new MissingEntityAttributeException(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 is null) - throw new RouteEntityNotFoundException(dataType.Name, id); + throw new EntityNotFoundException(dataType.Name, id); bindingContext.Result = ModelBindingResult.Success(entity); } @@ -62,7 +64,8 @@ protected bool SupportsType(Type modelType) var method = receiver.GetType().GetMethod(nameof(IBindingModelDataReceiver.GetByKey)); if (method == null) - throw new BindingReceiverContractException(nameof(IBindingModelDataReceiver.GetByKey), receiver.GetType()); + throw new BindingReceiverContractException(nameof(IBindingModelDataReceiver.GetByKey), + receiver.GetType()); var parameters = new object?[] { id, context.HttpContext, metaData }; var taskObj = method.Invoke(receiver, parameters); @@ -80,10 +83,10 @@ protected bool SupportsType(Type modelType) if (result is null) return default; - + if (result is not TValue typedResult) throw new UnexpectedBindingResultException(typeof(TValue), result?.GetType()); return typedResult; } -} +} \ No newline at end of file diff --git a/EntityInjector.Route/EntityInjector.Route.csproj b/EntityInjector.Route/EntityInjector.Route.csproj index dd9eabd..200d1f3 100644 --- a/EntityInjector.Route/EntityInjector.Route.csproj +++ b/EntityInjector.Route/EntityInjector.Route.csproj @@ -18,9 +18,15 @@ - - - + + + + + + + + + diff --git a/EntityInjector.Route/Exceptions/Middleware/IRouteBindingProblemDetailsFactory.cs b/EntityInjector.Route/Exceptions/Middleware/IRouteBindingProblemDetailsFactory.cs deleted file mode 100644 index 193067f..0000000 --- a/EntityInjector.Route/Exceptions/Middleware/IRouteBindingProblemDetailsFactory.cs +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index d91c2ca..0000000 --- a/EntityInjector.Route/Exceptions/Middleware/RouteBindingApplicationBuilderExtensions.cs +++ /dev/null @@ -1,11 +0,0 @@ -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/RouteBindingServiceCollectionExtensions.cs b/EntityInjector.Route/Exceptions/Middleware/RouteBindingServiceCollectionExtensions.cs deleted file mode 100644 index 95e0f6b..0000000 --- a/EntityInjector.Route/Exceptions/Middleware/RouteBindingServiceCollectionExtensions.cs +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index b91246c..0000000 --- a/EntityInjector.Route/Exceptions/StatusExceptions.cs +++ /dev/null @@ -1,84 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace EntityInjector.Route.Exceptions; - -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 string ParameterName { get; } = parameterName; -} diff --git a/EntityInjector.Route/Filters/FromRouteToEntityOperationFilter.cs b/EntityInjector.Route/Filters/FromRouteToEntityOperationFilter.cs new file mode 100644 index 0000000..4afbf9b --- /dev/null +++ b/EntityInjector.Route/Filters/FromRouteToEntityOperationFilter.cs @@ -0,0 +1,46 @@ +using EntityInjector.Core.Exceptions; +using EntityInjector.Route.Attributes; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace EntityInjector.Route.Filters; + +public class FromRouteToEntityOperationFilter : IOperationFilter +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + // Remove parameters decorated with FromRouteToEntityAttribute + var parametersToHide = context.ApiDescription.ParameterDescriptions + .Where(desc => + desc.ModelMetadata is DefaultModelMetadata metadata && + metadata.Attributes.ParameterAttributes?.OfType().Any() == true) + .ToList(); + + foreach (var parameterToHide in parametersToHide) + { + var parameter = operation.Parameters + .FirstOrDefault(p => string.Equals(p.Name, parameterToHide.Name, StringComparison.Ordinal)); + if (parameter != null) operation.Parameters.Remove(parameter); + } + + // Add OpenAPI responses for known EntityBindingException types + var types = typeof(EntityBindingException).Assembly + .GetTypes() + .Where(t => typeof(EntityBindingException).IsAssignableFrom(t) && + t.IsSealed && + t.IsClass && + t.GetInterfaces().Contains(typeof(IExceptionMetadata))) + .ToList(); + + foreach (var type in types) + { + if (Activator.CreateInstance(type) is not IExceptionMetadata instance) + continue; + + var key = instance.StatusCode.ToString(); + if (!operation.Responses.ContainsKey(key)) + operation.Responses.Add(key, new OpenApiResponse { Description = instance.DefaultDescription }); + } + } +} \ No newline at end of file diff --git a/EntityInjector.Route/README.md b/EntityInjector.Route/README.md index 5978700..521ef0d 100644 --- a/EntityInjector.Route/README.md +++ b/EntityInjector.Route/README.md @@ -31,19 +31,30 @@ options.ModelMetadataDetailsProviders.Add(new GuidEntityBindingMetadataProvider< 3. (Optionally) Configure exception handling: ```csharp -services.AddRouteBinding(); +services.AddEntityBinding(); ... -app.UseRouteBinding(); +app.UseEntityBinding(); ``` -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: +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.UseEntityBinding()` and instead add your own: + ```csharp services.TryAddSingleton(); ``` An example of this can be found in the `CustomFactoryExceptionTests` +4. (Optionally) Add a swagger filter for the entities: + +```csharp +services.PostConfigureAll(o => +{ + o.OperationFilter(); +}); +``` + ## Samples See the Sample projects for demonstration on how to: @@ -57,12 +68,14 @@ See the Sample projects for demonstration on how to: ## Extensibility -You can extend `FromRouteToEntityBindingMetadataProvider` or `FromRouteToCollectionBindingMetadataProvider` to support custom key types beyond what is included. +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. -If multiple keys are needed, you can create a custom attribute extending `FromRouteToEntityAttribute` with a different name. +If multiple keys are needed, you can create a custom attribute extending `FromRouteToEntityAttribute` with a different +name. This is technically possible but not recommended due to potential for confusion. diff --git a/EntityInjector.Samples.CosmosTest/Controllers/ProductController.cs b/EntityInjector.Samples.CosmosTest/Controllers/ProductController.cs index f24d0af..aba7075 100644 --- a/EntityInjector.Samples.CosmosTest/Controllers/ProductController.cs +++ b/EntityInjector.Samples.CosmosTest/Controllers/ProductController.cs @@ -1,4 +1,4 @@ -using EntityInjector.Route.Middleware.Attributes; +using EntityInjector.Route.Attributes; using EntityInjector.Samples.CosmosTest.Models; using Microsoft.AspNetCore.Mvc; diff --git a/EntityInjector.Samples.CosmosTest/Controllers/UserController.cs b/EntityInjector.Samples.CosmosTest/Controllers/UserController.cs index 30a55aa..62f0132 100644 --- a/EntityInjector.Samples.CosmosTest/Controllers/UserController.cs +++ b/EntityInjector.Samples.CosmosTest/Controllers/UserController.cs @@ -1,4 +1,4 @@ -using EntityInjector.Route.Middleware.Attributes; +using EntityInjector.Route.Attributes; using EntityInjector.Samples.CosmosTest.Models; using Microsoft.AspNetCore.Mvc; diff --git a/EntityInjector.Samples.CosmosTest/DataReceivers/GuidUserDataReceiver.cs b/EntityInjector.Samples.CosmosTest/DataReceivers/GuidUserDataReceiver.cs index fcff8f3..7ae56c6 100644 --- a/EntityInjector.Samples.CosmosTest/DataReceivers/GuidUserDataReceiver.cs +++ b/EntityInjector.Samples.CosmosTest/DataReceivers/GuidUserDataReceiver.cs @@ -1,4 +1,5 @@ -using EntityInjector.Route.Interfaces; +using System.Net; +using EntityInjector.Core.Interfaces; using EntityInjector.Samples.CosmosTest.Setup; using Microsoft.AspNetCore.Http; using Microsoft.Azure.Cosmos; @@ -9,6 +10,7 @@ namespace EntityInjector.Samples.CosmosTest.DataReceivers; public class GuidUserDataReceiver(CosmosContainer cosmosContainer) : IBindingModelDataReceiver { private readonly Container _container = cosmosContainer.Container; + public async Task GetByKey(Guid key, HttpContext httpContext, Dictionary metaData) { try @@ -17,25 +19,23 @@ public class GuidUserDataReceiver(CosmosContainer cosmosContainer) : IBind var response = await _container.ReadItemAsync(key.ToString(), pk); return response.Resource; } - catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { return null; } } - public async Task> GetByKeys(List keys, HttpContext httpContext, Dictionary metaData) + public async Task> GetByKeys(List keys, HttpContext httpContext, + Dictionary metaData) { var result = new Dictionary(); foreach (var key in keys) { var user = await GetByKey(key, httpContext, metaData); - if (user != null) - { - result[key] = user; - } + if (user != null) result[key] = user; } return result; } -} +} \ No newline at end of file diff --git a/EntityInjector.Samples.CosmosTest/DataReceivers/IntProductDataReceiver.cs b/EntityInjector.Samples.CosmosTest/DataReceivers/IntProductDataReceiver.cs index 96a8851..5334a8a 100644 --- a/EntityInjector.Samples.CosmosTest/DataReceivers/IntProductDataReceiver.cs +++ b/EntityInjector.Samples.CosmosTest/DataReceivers/IntProductDataReceiver.cs @@ -1,4 +1,4 @@ -using EntityInjector.Route.Interfaces; +using EntityInjector.Core.Interfaces; using EntityInjector.Samples.CosmosTest.Models; using EntityInjector.Samples.CosmosTest.Setup; using Microsoft.AspNetCore.Http; @@ -10,7 +10,7 @@ namespace EntityInjector.Samples.CosmosTest.DataReceivers; public class IntProductDataReceiver(CosmosContainer cosmosContainer) : IBindingModelDataReceiver { private readonly Container _container = cosmosContainer.Container; - + public async Task GetByKey(int key, HttpContext httpContext, Dictionary metaData) { var query = _container.GetItemLinqQueryable(true) @@ -26,7 +26,8 @@ public class IntProductDataReceiver(CosmosContainer cosmosContainer) : return null; } - public async Task> GetByKeys(List keys, HttpContext httpContext, Dictionary metaData) + public async Task> GetByKeys(List keys, HttpContext httpContext, + Dictionary metaData) { var stringKeys = keys.Select(k => k.ToString()).ToList(); @@ -38,10 +39,7 @@ public async Task> GetByKeys(List keys, HttpContex while (query.HasMoreResults) { var response = await query.ReadNextAsync(); - foreach (var product in response.Resource) - { - result[int.Parse(product.Id)] = product; - } + foreach (var product in response.Resource) result[int.Parse(product.Id)] = product; } return result; diff --git a/EntityInjector.Samples.CosmosTest/DataReceivers/StringUserDataReceiver.cs b/EntityInjector.Samples.CosmosTest/DataReceivers/StringUserDataReceiver.cs index 6d176d4..eb582fd 100644 --- a/EntityInjector.Samples.CosmosTest/DataReceivers/StringUserDataReceiver.cs +++ b/EntityInjector.Samples.CosmosTest/DataReceivers/StringUserDataReceiver.cs @@ -1,4 +1,5 @@ -using EntityInjector.Route.Interfaces; +using System.Net; +using EntityInjector.Core.Interfaces; using EntityInjector.Samples.CosmosTest.Setup; using Microsoft.AspNetCore.Http; using Microsoft.Azure.Cosmos; @@ -9,6 +10,7 @@ namespace EntityInjector.Samples.CosmosTest.DataReceivers; public class StringUserDataReceiver(CosmosContainer cosmosContainer) : IBindingModelDataReceiver { private readonly Container _container = cosmosContainer.Container; + public async Task GetByKey(string key, HttpContext httpContext, Dictionary metaData) { try @@ -17,23 +19,21 @@ public class StringUserDataReceiver(CosmosContainer cosmosContainer) : IBi var response = await _container.ReadItemAsync(key, pk); return response.Resource; } - catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { return null; } } - public async Task> GetByKeys(List keys, HttpContext httpContext, Dictionary metaData) + public async Task> GetByKeys(List keys, HttpContext httpContext, + Dictionary metaData) { var result = new Dictionary(); foreach (var key in keys) { var user = await GetByKey(key, httpContext, metaData); - if (user != null) - { - result[key] = user; - } + if (user != null) result[key] = user; } return result; diff --git a/EntityInjector.Samples.CosmosTest/EntityInjector.Samples.CosmosTest.csproj b/EntityInjector.Samples.CosmosTest/EntityInjector.Samples.CosmosTest.csproj index ff63e5d..f24f0eb 100644 --- a/EntityInjector.Samples.CosmosTest/EntityInjector.Samples.CosmosTest.csproj +++ b/EntityInjector.Samples.CosmosTest/EntityInjector.Samples.CosmosTest.csproj @@ -9,20 +9,20 @@ - - - - - - + + + + + + - all - runtime; build; native; contentfiles; analyzers; buildtransitive + all + runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/EntityInjector.Samples.CosmosTest/Models/Product.cs b/EntityInjector.Samples.CosmosTest/Models/Product.cs index 0f05a35..dbf2938 100644 --- a/EntityInjector.Samples.CosmosTest/Models/Product.cs +++ b/EntityInjector.Samples.CosmosTest/Models/Product.cs @@ -4,11 +4,9 @@ namespace EntityInjector.Samples.CosmosTest.Models; public class Product { - [JsonProperty("id")] - public string Id { get; set; } = default!; // Id may not be int in cosmos + [JsonProperty("id")] public string Id { get; set; } = default!; // Id may not be int in cosmos [JsonProperty("name")] public string Name { get; set; } = ""; [JsonProperty("price")] public decimal Price { get; set; } - } \ No newline at end of file diff --git a/EntityInjector.Samples.CosmosTest/Setup/CosmosTestFixture.cs b/EntityInjector.Samples.CosmosTest/Setup/CosmosTestFixture.cs index 16a7f77..7053dd1 100644 --- a/EntityInjector.Samples.CosmosTest/Setup/CosmosTestFixture.cs +++ b/EntityInjector.Samples.CosmosTest/Setup/CosmosTestFixture.cs @@ -7,12 +7,13 @@ namespace EntityInjector.Samples.CosmosTest.Setup; public class CosmosTestFixture : IAsyncLifetime { + private const string AccountKey = + "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="; + public CosmosClient Client { get; private set; } = default!; public Container UsersContainer { get; private set; } = default!; public Container ProductsContainer { get; private set; } = default!; - private const string AccountKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="; - public async Task InitializeAsync() { var connectionString = $"AccountEndpoint=https://localhost:8081/;AccountKey={AccountKey};"; @@ -21,8 +22,8 @@ public async Task InitializeAsync() ConnectionMode = ConnectionMode.Gateway, HttpClientFactory = () => new HttpClient(new HttpClientHandler { - ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator - + ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator }) }); @@ -30,10 +31,16 @@ public async Task InitializeAsync() UsersContainer = await db.Database.CreateContainerIfNotExistsAsync("Users", "/id"); ProductsContainer = await db.Database.CreateContainerIfNotExistsAsync("Products", "/id"); - + await SeedDataAsync(); } + public Task DisposeAsync() + { + Client?.Dispose(); + return Task.CompletedTask; + } + private async Task SeedDataAsync() { var iterator = UsersContainer.GetItemQueryIterator("SELECT TOP 1 c.id FROM c"); @@ -43,10 +50,13 @@ private async Task SeedDataAsync() { var user1 = new User { Id = Guid.NewGuid(), Name = "Alice", Age = 20 }; var user2 = new User { Id = Guid.NewGuid(), Name = "Bob", Age = 18 }; + var user3 = new User { Id = Guid.NewGuid(), Name = "Carol", Age = 25 }; await UsersContainer.UpsertItemAsync(user1, new PartitionKey(user1.Id.ToString())); await UsersContainer.UpsertItemAsync(user2, new PartitionKey(user2.Id.ToString())); + await UsersContainer.UpsertItemAsync(user3, new PartitionKey(user3.Id.ToString())); } + iterator = ProductsContainer.GetItemQueryIterator("SELECT TOP 1 c.id FROM c"); response = await iterator.ReadNextAsync(); @@ -59,10 +69,4 @@ private async Task SeedDataAsync() await ProductsContainer.UpsertItemAsync(product2, new PartitionKey(product2.Id)); } } - - public Task DisposeAsync() - { - Client?.Dispose(); - return Task.CompletedTask; - } -} +} \ No newline at end of file diff --git a/EntityInjector.Samples.CosmosTest/Tests/MultipleModelsTests.cs b/EntityInjector.Samples.CosmosTest/Tests/MultipleModelsTests.cs index b5501cb..e7e8dd4 100644 --- a/EntityInjector.Samples.CosmosTest/Tests/MultipleModelsTests.cs +++ b/EntityInjector.Samples.CosmosTest/Tests/MultipleModelsTests.cs @@ -1,7 +1,7 @@ using System.Text.Json; -using EntityInjector.Route.Interfaces; -using EntityInjector.Route.Middleware.BindingMetadata.Collection; -using EntityInjector.Route.Middleware.BindingMetadata.Entity; +using EntityInjector.Core.Interfaces; +using EntityInjector.Route.BindingMetadata.Collection; +using EntityInjector.Route.BindingMetadata.Entity; using EntityInjector.Samples.CosmosTest.DataReceivers; using EntityInjector.Samples.CosmosTest.Models; using EntityInjector.Samples.CosmosTest.Setup; @@ -21,11 +21,12 @@ 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() @@ -89,7 +90,7 @@ private async Task> GetSeededProductsAsync() return products; } - [RetryFact(maxRetries: 10, delayBetweenRetriesMs: 1000)] + [RetryFact(10, 1000)] public async Task CanBindFromRouteToUserEntityViaGuid() { var users = await GetSeededUsersAsync(); @@ -106,7 +107,7 @@ public async Task CanBindFromRouteToUserEntityViaGuid() Assert.Equal(expectedUser.Age, result.Age); } - [RetryFact(maxRetries: 10, delayBetweenRetriesMs: 1000)] + [RetryFact(10, 1000)] public async Task CanBindFromRouteToProductEntityViaInt() { var products = await GetSeededProductsAsync(); @@ -123,7 +124,7 @@ public async Task CanBindFromRouteToProductEntityViaInt() Assert.Equal(expectedProduct.Price, result.Price); } - [RetryFact(maxRetries: 10, delayBetweenRetriesMs: 1000)] + [RetryFact(10, 1000)] public async Task CanFetchMultipleUsersByHttpRequest() { var users = (await GetSeededUsersAsync()).Take(2).ToList(); @@ -147,7 +148,7 @@ public async Task CanFetchMultipleUsersByHttpRequest() } } - [RetryFact(maxRetries: 10, delayBetweenRetriesMs: 1000)] + [RetryFact(10, 1000)] public async Task CanFetchMultipleProductsByHttpRequest() { var products = (await GetSeededProductsAsync()).Take(2).ToList(); @@ -170,4 +171,4 @@ public async Task CanFetchMultipleProductsByHttpRequest() Assert.Equal(expectedProduct.Price, actualProduct.Price); } } -} +} \ No newline at end of file diff --git a/EntityInjector.Samples.CosmosTest/Tests/StringKeyTests.cs b/EntityInjector.Samples.CosmosTest/Tests/StringKeyTests.cs index 94a6163..eacfe2b 100644 --- a/EntityInjector.Samples.CosmosTest/Tests/StringKeyTests.cs +++ b/EntityInjector.Samples.CosmosTest/Tests/StringKeyTests.cs @@ -1,7 +1,7 @@ using System.Text.Json; -using EntityInjector.Route.Interfaces; -using EntityInjector.Route.Middleware.BindingMetadata.Collection; -using EntityInjector.Route.Middleware.BindingMetadata.Entity; +using EntityInjector.Core.Interfaces; +using EntityInjector.Route.BindingMetadata.Collection; +using EntityInjector.Route.BindingMetadata.Entity; using EntityInjector.Samples.CosmosTest.DataReceivers; using EntityInjector.Samples.CosmosTest.Models; using EntityInjector.Samples.CosmosTest.Setup; @@ -21,11 +21,12 @@ 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() @@ -56,14 +57,14 @@ public StringKeyTests(CosmosTestFixture fixture) _fixture = fixture; } - [RetryFact(maxRetries: 10, delayBetweenRetriesMs: 1000)] + [RetryFact(10, 1000)] public async Task CanBindFromRouteToUserEntityViaString() { // Get a seeded user from Cosmos DB var iterator = _fixture.UsersContainer.GetItemLinqQueryable(true).ToFeedIterator(); var response = await iterator.ReadNextAsync(); var expectedUser = response.Resource.FirstOrDefault(); - + Assert.NotNull(expectedUser); var userId = expectedUser!.Id.ToString(); @@ -78,12 +79,12 @@ public async Task CanBindFromRouteToUserEntityViaString() Assert.Equal(expectedUser.Name, result.Name); } - [RetryFact(maxRetries: 10, delayBetweenRetriesMs: 1000)] + [RetryFact(10, 1000)] public async Task CanFetchMultipleUsersByHttpRequest() { var iterator = _fixture.UsersContainer.GetItemLinqQueryable(true).ToFeedIterator(); var users = new List(); - + while (iterator.HasMoreResults && users.Count < 2) { var response = await iterator.ReadNextAsync(); @@ -111,4 +112,4 @@ public async Task CanFetchMultipleUsersByHttpRequest() Assert.Equal(expectedUser.Age, actualUser.Age); } } -} +} \ No newline at end of file diff --git a/EntityInjector.Samples.PostgresTest/Controllers/InvalidUserController.cs b/EntityInjector.Samples.PostgresTest/Controllers/InvalidUserController.cs index 617463a..59bdd25 100644 --- a/EntityInjector.Samples.PostgresTest/Controllers/InvalidUserController.cs +++ b/EntityInjector.Samples.PostgresTest/Controllers/InvalidUserController.cs @@ -1,5 +1,5 @@ -using EntityInjector.Route.Middleware.Attributes; -using EntityInjector.Samples.PostgresTest.Models; +using EntityInjector.Route.Attributes; +using EntityInjector.Samples.PostgresTest.Models.Entities; using Microsoft.AspNetCore.Mvc; namespace EntityInjector.Samples.PostgresTest.Controllers; @@ -14,7 +14,7 @@ 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) diff --git a/EntityInjector.Samples.PostgresTest/Controllers/PetController.cs b/EntityInjector.Samples.PostgresTest/Controllers/PetController.cs new file mode 100644 index 0000000..3f1020d --- /dev/null +++ b/EntityInjector.Samples.PostgresTest/Controllers/PetController.cs @@ -0,0 +1,69 @@ +using EntityInjector.Samples.PostgresTest.Models; +using EntityInjector.Samples.PostgresTest.Models.Entities; +using Microsoft.AspNetCore.Mvc; + +namespace EntityInjector.Samples.PostgresTest.Controllers; + +[ApiController] +[Route("api/pets")] +public class PetController : ControllerBase +{ + [HttpPost] + public ActionResult FakeCreate([FromBody] PetModel pet) + { + return Ok(new PetDto + { + Id = pet.Id, + Name = pet.Name, + Species = pet.Species, + Owner = pet.Owner is null + ? null + : new User + { + Id = pet.Owner.Id, + Name = pet.Owner.Name, + Age = pet.Owner.Age + } + }); + } + + [HttpPost("bulk")] + public ActionResult> FakeCreateBulk([FromBody] List pets) + { + return pets.Select(p => new PetDto + { + Id = p.Id, + Name = p.Name, + Species = p.Species, + Owner = p.Owner + }).ToList(); + } + + [HttpPost("by-name")] + public ActionResult> FakeCreateByName([FromBody] Dictionary petsByName) + { + var result = petsByName.ToDictionary( + kvp => kvp.Key, + kvp => new PetDto + { + Id = kvp.Value.Id, + Name = kvp.Value.Name, + Species = kvp.Value.Species, + Owner = kvp.Value.Owner + }); + + return Ok(result); + } + + [HttpPost("nullable")] + public ActionResult PostNullableOwner([FromBody] PetModelWithNullableOwner model) + { + return Ok(new PetDto + { + Id = model.Id, + Name = model.Name, + Species = model.Species, + Owner = model.Owner + }); + } +} \ No newline at end of file diff --git a/EntityInjector.Samples.PostgresTest/Controllers/ProductController.cs b/EntityInjector.Samples.PostgresTest/Controllers/ProductController.cs index ceefdfb..4bc2a17 100644 --- a/EntityInjector.Samples.PostgresTest/Controllers/ProductController.cs +++ b/EntityInjector.Samples.PostgresTest/Controllers/ProductController.cs @@ -1,5 +1,5 @@ -using EntityInjector.Route.Middleware.Attributes; -using EntityInjector.Samples.PostgresTest.Models; +using EntityInjector.Route.Attributes; +using EntityInjector.Samples.PostgresTest.Models.Entities; using Microsoft.AspNetCore.Mvc; namespace EntityInjector.Samples.PostgresTest.Controllers; diff --git a/EntityInjector.Samples.PostgresTest/Controllers/ProjectController.cs b/EntityInjector.Samples.PostgresTest/Controllers/ProjectController.cs new file mode 100644 index 0000000..d85b040 --- /dev/null +++ b/EntityInjector.Samples.PostgresTest/Controllers/ProjectController.cs @@ -0,0 +1,31 @@ +using EntityInjector.Samples.PostgresTest.Models; +using Microsoft.AspNetCore.Mvc; + +namespace EntityInjector.Samples.PostgresTest.Controllers; + +[ApiController] +[Route("api/projects")] +public class ProjectController : ControllerBase +{ + [HttpPost] + public ActionResult FakeCreateProject([FromBody] ProjectModel model) + { + return Ok(new ProjectDto + { + Id = model.Id, + Name = model.Name, + Leads = model.Leads! + }); + } + + [HttpPost("nullable")] + public ActionResult PostProjectWithNullableLeads([FromBody] ProjectModelWithNullableLeads model) + { + return Ok(new ProjectDto + { + Id = model.Id, + Name = model.Name, + Leads = model.Leads + }); + } +} \ No newline at end of file diff --git a/EntityInjector.Samples.PostgresTest/Controllers/UserController.cs b/EntityInjector.Samples.PostgresTest/Controllers/UserController.cs index 6a66156..beed936 100644 --- a/EntityInjector.Samples.PostgresTest/Controllers/UserController.cs +++ b/EntityInjector.Samples.PostgresTest/Controllers/UserController.cs @@ -1,5 +1,5 @@ -using EntityInjector.Route.Middleware.Attributes; -using EntityInjector.Samples.PostgresTest.Models; +using EntityInjector.Route.Attributes; +using EntityInjector.Samples.PostgresTest.Models.Entities; using Microsoft.AspNetCore.Mvc; namespace EntityInjector.Samples.PostgresTest.Controllers; diff --git a/EntityInjector.Samples.PostgresTest/DataReceivers/GuidUserDataReceiver.cs b/EntityInjector.Samples.PostgresTest/DataReceivers/GuidUserDataReceiver.cs index 229dd09..28396a9 100644 --- a/EntityInjector.Samples.PostgresTest/DataReceivers/GuidUserDataReceiver.cs +++ b/EntityInjector.Samples.PostgresTest/DataReceivers/GuidUserDataReceiver.cs @@ -1,5 +1,5 @@ -using EntityInjector.Route.Interfaces; -using EntityInjector.Samples.PostgresTest.Models; +using EntityInjector.Core.Interfaces; +using EntityInjector.Samples.PostgresTest.Models.Entities; using EntityInjector.Samples.PostgresTest.Setup; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; diff --git a/EntityInjector.Samples.PostgresTest/DataReceivers/IntProductDataReceiver.cs b/EntityInjector.Samples.PostgresTest/DataReceivers/IntProductDataReceiver.cs index d041814..aa48952 100644 --- a/EntityInjector.Samples.PostgresTest/DataReceivers/IntProductDataReceiver.cs +++ b/EntityInjector.Samples.PostgresTest/DataReceivers/IntProductDataReceiver.cs @@ -1,5 +1,5 @@ -using EntityInjector.Route.Interfaces; -using EntityInjector.Samples.PostgresTest.Models; +using EntityInjector.Core.Interfaces; +using EntityInjector.Samples.PostgresTest.Models.Entities; using EntityInjector.Samples.PostgresTest.Setup; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; diff --git a/EntityInjector.Samples.PostgresTest/DataReceivers/StringUserDataReceiver.cs b/EntityInjector.Samples.PostgresTest/DataReceivers/StringUserDataReceiver.cs index b207447..5a0a1a8 100644 --- a/EntityInjector.Samples.PostgresTest/DataReceivers/StringUserDataReceiver.cs +++ b/EntityInjector.Samples.PostgresTest/DataReceivers/StringUserDataReceiver.cs @@ -1,5 +1,5 @@ -using EntityInjector.Route.Interfaces; -using EntityInjector.Samples.PostgresTest.Models; +using EntityInjector.Core.Interfaces; +using EntityInjector.Samples.PostgresTest.Models.Entities; using EntityInjector.Samples.PostgresTest.Setup; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; diff --git a/EntityInjector.Samples.PostgresTest/EntityInjector.Samples.PostgresTest.csproj b/EntityInjector.Samples.PostgresTest/EntityInjector.Samples.PostgresTest.csproj index dcf0c64..050f0ae 100644 --- a/EntityInjector.Samples.PostgresTest/EntityInjector.Samples.PostgresTest.csproj +++ b/EntityInjector.Samples.PostgresTest/EntityInjector.Samples.PostgresTest.csproj @@ -1,19 +1,20 @@  + - + - + - + - all - runtime; build; native; contentfiles; analyzers; buildtransitive + all + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/EntityInjector.Samples.PostgresTest/Models/Product.cs b/EntityInjector.Samples.PostgresTest/Models/Entities/Product.cs similarity index 83% rename from EntityInjector.Samples.PostgresTest/Models/Product.cs rename to EntityInjector.Samples.PostgresTest/Models/Entities/Product.cs index a298209..c3b810b 100644 --- a/EntityInjector.Samples.PostgresTest/Models/Product.cs +++ b/EntityInjector.Samples.PostgresTest/Models/Entities/Product.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace EntityInjector.Samples.PostgresTest.Models; +namespace EntityInjector.Samples.PostgresTest.Models.Entities; public class Product { diff --git a/EntityInjector.Samples.PostgresTest/Models/User.cs b/EntityInjector.Samples.PostgresTest/Models/Entities/User.cs similarity index 83% rename from EntityInjector.Samples.PostgresTest/Models/User.cs rename to EntityInjector.Samples.PostgresTest/Models/Entities/User.cs index 532ed76..a905060 100644 --- a/EntityInjector.Samples.PostgresTest/Models/User.cs +++ b/EntityInjector.Samples.PostgresTest/Models/Entities/User.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace EntityInjector.Samples.PostgresTest.Models; +namespace EntityInjector.Samples.PostgresTest.Models.Entities; [Table("users")] public class User diff --git a/EntityInjector.Samples.PostgresTest/Models/Pet.cs b/EntityInjector.Samples.PostgresTest/Models/Pet.cs new file mode 100644 index 0000000..1e3fd15 --- /dev/null +++ b/EntityInjector.Samples.PostgresTest/Models/Pet.cs @@ -0,0 +1,38 @@ +using EntityInjector.Property.Attributes; +using EntityInjector.Samples.PostgresTest.Models.Entities; + +namespace EntityInjector.Samples.PostgresTest.Models; + +public class PetModel +{ + public Guid Id { get; set; } + + public string Name { get; set; } = ""; + + public string Species { get; set; } = ""; + + public Guid OwnerId { get; set; } + + [FromPropertyToEntity(nameof(OwnerId))] + public User? Owner { get; set; } +} + +public class PetModelWithNullableOwner +{ + public Guid Id { get; set; } + public string Name { get; set; } = ""; + public string Species { get; set; } = ""; + + public Guid? OwnerId { get; set; } + + [FromPropertyToEntity(nameof(OwnerId))] + public User? Owner { get; set; } +} + +public class PetDto +{ + public Guid Id { get; set; } + public string Name { get; set; } = ""; + public string Species { get; set; } = ""; + public User? Owner { get; set; } +} \ No newline at end of file diff --git a/EntityInjector.Samples.PostgresTest/Models/Project.cs b/EntityInjector.Samples.PostgresTest/Models/Project.cs new file mode 100644 index 0000000..fdbd9be --- /dev/null +++ b/EntityInjector.Samples.PostgresTest/Models/Project.cs @@ -0,0 +1,35 @@ +using EntityInjector.Property.Attributes; +using EntityInjector.Samples.PostgresTest.Models.Entities; + +namespace EntityInjector.Samples.PostgresTest.Models; + +public class ProjectModel +{ + public Guid Id { get; set; } + + public string Name { get; set; } = ""; + + public List LeadIds { get; set; } = []; + + [FromPropertyToEntity(nameof(LeadIds))] + public List Leads { get; set; } = []; +} + +public class ProjectModelWithNullableLeads +{ + public Guid Id { get; set; } + + public string Name { get; set; } = ""; + + public List LeadIds { get; set; } = []; + + [FromPropertyToEntity(nameof(LeadIds), "includeNulls=true")] + public List Leads { get; set; } = []; +} + +public class ProjectDto +{ + public Guid Id { get; set; } + public string Name { get; set; } = ""; + public List Leads { get; set; } = []; +} \ No newline at end of file diff --git a/EntityInjector.Samples.PostgresTest/README.md b/EntityInjector.Samples.PostgresTest/README.md index 7e13708..545f01f 100644 --- a/EntityInjector.Samples.PostgresTest/README.md +++ b/EntityInjector.Samples.PostgresTest/README.md @@ -1,6 +1,7 @@ # EntityInjector.Samples.PostgresTest -This sample demonstrates how to use EntityInjector with a Postgres database using Entity Framework Core and TestContainers. +This sample demonstrates how to use EntityInjector with a Postgres database using Entity Framework Core and +TestContainers. It shows how to bind route parameters directly to entities using dependency injection. ## Prerequisites diff --git a/EntityInjector.Samples.PostgresTest/Setup/TestDbContext.cs b/EntityInjector.Samples.PostgresTest/Setup/TestDbContext.cs index 5b34869..2211511 100644 --- a/EntityInjector.Samples.PostgresTest/Setup/TestDbContext.cs +++ b/EntityInjector.Samples.PostgresTest/Setup/TestDbContext.cs @@ -1,4 +1,4 @@ -using EntityInjector.Samples.PostgresTest.Models; +using EntityInjector.Samples.PostgresTest.Models.Entities; using Microsoft.EntityFrameworkCore; namespace EntityInjector.Samples.PostgresTest.Setup; diff --git a/EntityInjector.Samples.PostgresTest/Tests/Property/PropertyExceptionTests.cs b/EntityInjector.Samples.PostgresTest/Tests/Property/PropertyExceptionTests.cs new file mode 100644 index 0000000..786d93d --- /dev/null +++ b/EntityInjector.Samples.PostgresTest/Tests/Property/PropertyExceptionTests.cs @@ -0,0 +1,113 @@ +using System.Net.Http.Json; +using System.Text.Json; +using EntityInjector.Core.Exceptions; +using EntityInjector.Core.Exceptions.Middleware; +using EntityInjector.Core.Interfaces; +using EntityInjector.Property.Filters; +using EntityInjector.Samples.PostgresTest.DataReceivers; +using EntityInjector.Samples.PostgresTest.Models; +using EntityInjector.Samples.PostgresTest.Models.Entities; +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 Swashbuckle.AspNetCore.SwaggerGen; +using Xunit; + +namespace EntityInjector.Samples.PostgresTest.Tests.Property; + +public class PropertyExceptionTests : IClassFixture +{ + private readonly PostgresTestFixture _fixture; + + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public PropertyExceptionTests(PostgresTestFixture fixture) + { + _fixture = fixture; + } + + private HttpClient CreateClient(Action? overrideServices = null) + { + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddSingleton(_fixture.DbContext); + services.AddEntityBinding(); + + services.AddScoped, GuidUserDataReceiver>(); + + services.AddSingleton(); + services.AddControllers(); + + services.PostConfigureAll(options => + { + options.Filters.Add(); + }); + + services.PostConfigureAll(o => + { + o.SchemaFilter(); + }); + + // Apply test-specific overrides + overrideServices?.Invoke(services); + }) + .Configure(app => + { + app.UseRouting(); + app.UseEntityBinding(); + app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); + }); + + return new TestServer(builder).CreateClient(); + } + + + [Fact] + public async Task Returns404_WhenUserDoesNotExist() + { + var client = CreateClient(); + var payload = new { name = "Whiskers", ownerId = Guid.NewGuid() }; + + var response = await client.PostAsJsonAsync("/api/pets", payload); + + var body = await response.Content.ReadAsStringAsync(); + var problem = JsonSerializer.Deserialize(body, _jsonOptions); + + var expected = new EntityNotFoundException(nameof(PetModel.Owner), payload.ownerId); + + Assert.NotNull(problem); + Assert.Equal(expected.StatusCode, problem!.Status); + Assert.Equal(expected.Message, problem.Detail); + } + + [Fact] + public async Task Returns500_WhenNoBindingReceiverRegistered() + { + var client = CreateClient(services => + { + // Remove existing receiver registration + var descriptor = services.Single(d => + d.ServiceType == typeof(IBindingModelDataReceiver)); + services.Remove(descriptor); + }); + + var payload = new { name = "Whiskers", ownerId = Guid.NewGuid() }; + var response = await client.PostAsJsonAsync("/api/pets", payload); + 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); + } +} \ No newline at end of file diff --git a/EntityInjector.Samples.PostgresTest/Tests/Property/PropertyToEntityTests.cs b/EntityInjector.Samples.PostgresTest/Tests/Property/PropertyToEntityTests.cs new file mode 100644 index 0000000..db97428 --- /dev/null +++ b/EntityInjector.Samples.PostgresTest/Tests/Property/PropertyToEntityTests.cs @@ -0,0 +1,282 @@ +using System.Text; +using System.Text.Json; +using EntityInjector.Core.Interfaces; +using EntityInjector.Property.Filters; +using EntityInjector.Samples.PostgresTest.DataReceivers; +using EntityInjector.Samples.PostgresTest.Models; +using EntityInjector.Samples.PostgresTest.Models.Entities; +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.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Swashbuckle.AspNetCore.SwaggerGen; +using Xunit; + +namespace EntityInjector.Samples.PostgresTest.Tests.Property; + +public class PropertyToEntityTests : IClassFixture +{ + private readonly HttpClient _client; + private readonly PostgresTestFixture _fixture; + + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public PropertyToEntityTests(PostgresTestFixture fixture) + { + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddSingleton(fixture.DbContext); + + services.AddScoped, GuidUserDataReceiver>(); + + services.AddSingleton(); + services.AddControllers(); + + services.PostConfigureAll(options => + { + options.Filters.Add(); + }); + + services.PostConfigureAll(o => + { + o.SchemaFilter(); + }); + }) + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); + }); + + var server = new TestServer(builder); + _client = server.CreateClient(); + _fixture = fixture; + } + + [Fact] + public async Task CanHydrateUserEntityFromOwnerIdOnPost() + { + // Arrange + var expectedUser = await _fixture.DbContext.Users.FirstAsync(); + + var petModel = new PetModel + { + Id = Guid.NewGuid(), + Name = "Devon", + Species = "Cat", + OwnerId = expectedUser.Id + }; + + var json = JsonSerializer.Serialize(petModel); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + // Act + var response = await _client.PostAsync("/api/pets", content); + response.EnsureSuccessStatusCode(); + + var responseBody = await response.Content.ReadAsStringAsync(); + var returned = JsonSerializer.Deserialize(responseBody, _jsonOptions); + + // Assert + Assert.NotNull(returned); + Assert.Equal(petModel.Id, returned!.Id); + Assert.Equal(petModel.Name, returned.Name); + Assert.Equal(petModel.Species, returned.Species); + + Assert.NotNull(returned.Owner); + Assert.Equal(expectedUser.Id, returned.Owner!.Id); + Assert.Equal(expectedUser.Name, returned.Owner.Name); + } + + [Fact] + public async Task CanHydrateUserEntitiesFromOwnerIdsOnPostList() + { + // Arrange + var expectedUser = await _fixture.DbContext.Users.FirstAsync(); + + var petModels = new List + { + new() + { + Id = Guid.NewGuid(), + Name = "Bella", + Species = "Dog", + OwnerId = expectedUser.Id + }, + new() + { + Id = Guid.NewGuid(), + Name = "Max", + Species = "Dog", + OwnerId = expectedUser.Id + } + }; + + var json = JsonSerializer.Serialize(petModels); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + // Act + var response = await _client.PostAsync("/api/pets/bulk", content); + response.EnsureSuccessStatusCode(); + + var responseBody = await response.Content.ReadAsStringAsync(); + var returned = JsonSerializer.Deserialize>(responseBody, _jsonOptions); + + // Assert + Assert.NotNull(returned); + Assert.Equal(2, returned!.Count); + + foreach (var pet in returned) + { + Assert.Equal(expectedUser.Id, pet.Owner!.Id); + Assert.Equal(expectedUser.Name, pet.Owner.Name); + } + } + + [Fact] + public async Task CanHydrateUserEntitiesFromDictionaryOnPost() + { + // Arrange + var expectedUser = await _fixture.DbContext.Users.FirstAsync(); + + var petDict = new Dictionary + { + ["bella"] = new() + { + Id = Guid.NewGuid(), + Name = "Bella", + Species = "Dog", + OwnerId = expectedUser.Id + }, + ["max"] = new() + { + Id = Guid.NewGuid(), + Name = "Max", + Species = "Dog", + OwnerId = expectedUser.Id + } + }; + + var json = JsonSerializer.Serialize(petDict); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + // Act + var response = await _client.PostAsync("/api/pets/by-name", content); + response.EnsureSuccessStatusCode(); + + var responseBody = await response.Content.ReadAsStringAsync(); + var returned = JsonSerializer.Deserialize>(responseBody, _jsonOptions); + + // Assert + Assert.NotNull(returned); + Assert.Equal(2, returned!.Count); + + foreach (var pet in returned.Select(entry => entry.Value)) + { + Assert.Equal(expectedUser.Id, pet.Owner!.Id); + Assert.Equal(expectedUser.Name, pet.Owner.Name); + } + } + + [Fact] + public async Task CanHydrateUserEntitiesFromListOnPost() + { + // Arrange + var expectedUsers = await _fixture.DbContext.Users.Take(2).ToListAsync(); + + var project = new ProjectModel + { + Id = Guid.NewGuid(), + Name = "Alpha", + LeadIds = expectedUsers.Select(u => u.Id).ToList() + }; + + var json = JsonSerializer.Serialize(project); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + // Act + var response = await _client.PostAsync("/api/projects", content); + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadAsStringAsync(); + var returned = JsonSerializer.Deserialize(body, _jsonOptions); + + // Assert + Assert.NotNull(returned); + Assert.Equal(project.Id, returned!.Id); + Assert.Equal(project.Name, returned.Name); + Assert.Equal(2, returned.Leads.Count); + + foreach (var expected in expectedUsers) + Assert.Contains(returned.Leads, l => l.Id == expected.Id && l.Name == expected.Name); + } + + [Fact] + public async Task CanHandleNullableForeignKey_AsNull_SingleEntity() + { + var petModel = new PetModelWithNullableOwner + { + Id = Guid.NewGuid(), + Name = "Ghost", + Species = "Dog", + OwnerId = null + }; + + var json = JsonSerializer.Serialize(petModel); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _client.PostAsync("/api/pets/nullable", content); + response.EnsureSuccessStatusCode(); + + var responseBody = await response.Content.ReadAsStringAsync(); + var returned = JsonSerializer.Deserialize(responseBody, _jsonOptions); + + Assert.NotNull(returned); + Assert.Equal(petModel.Id, returned!.Id); + Assert.Null(returned.Owner); + } + + [Fact] + public async Task CanHydrateNullableUserListFromNullableGuids() + { + // Arrange + var users = await _fixture.DbContext.Users.Take(3).ToListAsync(); + var leadIds = new List { users[0].Id, Guid.NewGuid(), users[2].Id }; + + var model = new ProjectModelWithNullableLeads + { + Id = Guid.NewGuid(), + Name = "NullSafe Project", + LeadIds = leadIds + }; + + var json = JsonSerializer.Serialize(model); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + // Act + var response = await _client.PostAsync("/api/projects/nullable", content); + response.EnsureSuccessStatusCode(); + + var responseBody = await response.Content.ReadAsStringAsync(); + var returned = JsonSerializer.Deserialize(responseBody, _jsonOptions); + + // Assert + Assert.NotNull(returned); + Assert.Equal(model.Id, returned!.Id); + Assert.Equal(model.Name, returned.Name); + + Assert.NotNull(returned.Leads); + Assert.Equal(3, returned.Leads.Count); + Assert.Equal(users[0].Id, returned.Leads[0]!.Id); + Assert.Null(returned.Leads[1]); + Assert.Equal(users[2].Id, returned.Leads[2]!.Id); + } +} \ No newline at end of file diff --git a/EntityInjector.Samples.PostgresTest/Tests/CustomFactoryExceptionTests.cs b/EntityInjector.Samples.PostgresTest/Tests/Route/CustomFactoryExceptionTests.cs similarity index 74% rename from EntityInjector.Samples.PostgresTest/Tests/CustomFactoryExceptionTests.cs rename to EntityInjector.Samples.PostgresTest/Tests/Route/CustomFactoryExceptionTests.cs index 7408861..2d4b624 100644 --- a/EntityInjector.Samples.PostgresTest/Tests/CustomFactoryExceptionTests.cs +++ b/EntityInjector.Samples.PostgresTest/Tests/Route/CustomFactoryExceptionTests.cs @@ -1,12 +1,11 @@ -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.Core.Exceptions; +using EntityInjector.Core.Exceptions.Middleware; +using EntityInjector.Core.Interfaces; +using EntityInjector.Route.BindingMetadata.Entity; +using EntityInjector.Route.Filters; using EntityInjector.Samples.PostgresTest.DataReceivers; -using EntityInjector.Samples.PostgresTest.Models; +using EntityInjector.Samples.PostgresTest.Models.Entities; using EntityInjector.Samples.PostgresTest.Setup; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -15,27 +14,30 @@ using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Swashbuckle.AspNetCore.SwaggerGen; using Xunit; -namespace EntityInjector.Samples.PostgresTest.Tests; +namespace EntityInjector.Samples.PostgresTest.Tests.Route; 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(); - + services + .TryAddSingleton(); + // Use only one type of FromRoute bindings per Value type to avoid ambiguous bindings services.AddScoped, GuidUserDataReceiver>(); services.AddScoped, IntProductDataReceiver>(); @@ -47,15 +49,18 @@ public CustomFactoryExceptionTests(PostgresTestFixture fixture) { // Use only one type of FromRoute bindings per Value type to avoid ambiguous bindings options.ModelMetadataDetailsProviders.Add(new GuidEntityBindingMetadataProvider()); - + options.ModelMetadataDetailsProviders.Add(new IntEntityBindingMetadataProvider()); - + }); + services.PostConfigureAll(o => + { + o.OperationFilter(); }); }) .Configure(app => { app.UseRouting(); - app.UseRouteBinding(); + app.UseEntityBinding(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); }); @@ -63,28 +68,7 @@ public CustomFactoryExceptionTests(PostgresTestFixture fixture) _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() { @@ -95,8 +79,8 @@ public async Task InvalidRouteParameterFormatException_HasNoDetail() var body = await response.Content.ReadAsStringAsync(); var problem = JsonSerializer.Deserialize(body, _jsonOptions); - var expected = new InvalidRouteParameterFormatException("id", typeof(Guid), typeof(string)); - + var expected = new InvalidEntityParameterFormatException("id", typeof(Guid), typeof(string)); + Assert.NotNull(problem); Assert.Equal(expected.StatusCode, problem!.Status); Assert.Null(problem.Detail); @@ -113,14 +97,14 @@ public async Task RouteEntityNotFoundException_ForUser_HasDetail() var body = await response.Content.ReadAsStringAsync(); var problem = JsonSerializer.Deserialize(body, _jsonOptions); - var expected = new RouteEntityNotFoundException("User", userId); - + var expected = new EntityNotFoundException("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() { @@ -132,10 +116,28 @@ public async Task RouteEntityNotFoundException_ForProduct_HasNoDetail() var body = await response.Content.ReadAsStringAsync(); var problem = JsonSerializer.Deserialize(body, _jsonOptions); - var expected = new RouteEntityNotFoundException("Product", productId); - + var expected = new EntityNotFoundException("Product", productId); + Assert.NotNull(problem); Assert.Equal(expected.StatusCode, problem!.Status); Assert.Null(problem.Detail); } + + // Custom factory which does not include Detail, + // unless the exception is EntityNotFoundException on a Guid with the Entity User + public class CustomEntityBindingProblemDetailsFactory : IEntityBindingProblemDetailsFactory + { + public ProblemDetails Create(HttpContext context, EntityBindingException exception) + { + var problem = new ProblemDetails + { + Status = exception.StatusCode, + Instance = context.Request.Path + }; + + if (exception is EntityNotFoundException { EntityName: "User" }) problem.Detail = exception.Message; + + return problem; + } + } } \ No newline at end of file diff --git a/EntityInjector.Samples.PostgresTest/Tests/GuidExceptionTests.cs b/EntityInjector.Samples.PostgresTest/Tests/Route/GuidExceptionTests.cs similarity index 75% rename from EntityInjector.Samples.PostgresTest/Tests/GuidExceptionTests.cs rename to EntityInjector.Samples.PostgresTest/Tests/Route/GuidExceptionTests.cs index c2a7164..539075f 100644 --- a/EntityInjector.Samples.PostgresTest/Tests/GuidExceptionTests.cs +++ b/EntityInjector.Samples.PostgresTest/Tests/Route/GuidExceptionTests.cs @@ -1,12 +1,12 @@ -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.Core.Exceptions; +using EntityInjector.Core.Exceptions.Middleware; +using EntityInjector.Core.Interfaces; +using EntityInjector.Route.BindingMetadata.Collection; +using EntityInjector.Route.BindingMetadata.Entity; +using EntityInjector.Route.Filters; using EntityInjector.Samples.PostgresTest.DataReceivers; -using EntityInjector.Samples.PostgresTest.Models; +using EntityInjector.Samples.PostgresTest.Models.Entities; using EntityInjector.Samples.PostgresTest.Setup; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -14,26 +14,28 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using Swashbuckle.AspNetCore.SwaggerGen; using Xunit; -namespace EntityInjector.Samples.PostgresTest.Tests; +namespace EntityInjector.Samples.PostgresTest.Tests.Route; 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(); + services.AddEntityBinding(); // Use only one type of FromRoute bindings per Value type to avoid ambiguous bindings services.AddScoped, GuidUserDataReceiver>(); @@ -46,11 +48,15 @@ public GuidExceptionTests(PostgresTestFixture fixture) options.ModelMetadataDetailsProviders.Add(new GuidEntityBindingMetadataProvider()); options.ModelMetadataDetailsProviders.Add(new GuidCollectionBindingMetadataProvider()); }); + services.PostConfigureAll(o => + { + o.OperationFilter(); + }); }) .Configure(app => { app.UseRouting(); - app.UseRouteBinding(); + app.UseEntityBinding(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); }); @@ -58,7 +64,7 @@ public GuidExceptionTests(PostgresTestFixture fixture) _client = server.CreateClient(); _fixture = fixture; } - + [Fact] public async Task ReturnsBadRequestWhenGuidRouteParameterIsMalformed() { @@ -69,8 +75,8 @@ public async Task ReturnsBadRequestWhenGuidRouteParameterIsMalformed() var body = await response.Content.ReadAsStringAsync(); var problem = JsonSerializer.Deserialize(body, _jsonOptions); - var expected = new InvalidRouteParameterFormatException("id", typeof(Guid), typeof(string)); - + var expected = new InvalidEntityParameterFormatException("id", typeof(Guid), typeof(string)); + Assert.NotNull(problem); Assert.Equal(expected.StatusCode, problem!.Status); Assert.Contains("id", problem.Detail); diff --git a/EntityInjector.Samples.PostgresTest/Tests/MultipleModelsTests.cs b/EntityInjector.Samples.PostgresTest/Tests/Route/MultipleModelsTests.cs similarity index 91% rename from EntityInjector.Samples.PostgresTest/Tests/MultipleModelsTests.cs rename to EntityInjector.Samples.PostgresTest/Tests/Route/MultipleModelsTests.cs index 8766b45..f72d16c 100644 --- a/EntityInjector.Samples.PostgresTest/Tests/MultipleModelsTests.cs +++ b/EntityInjector.Samples.PostgresTest/Tests/Route/MultipleModelsTests.cs @@ -1,9 +1,10 @@ using System.Text.Json; -using EntityInjector.Route.Interfaces; -using EntityInjector.Route.Middleware.BindingMetadata.Collection; -using EntityInjector.Route.Middleware.BindingMetadata.Entity; +using EntityInjector.Core.Interfaces; +using EntityInjector.Route.BindingMetadata.Collection; +using EntityInjector.Route.BindingMetadata.Entity; +using EntityInjector.Route.Filters; using EntityInjector.Samples.PostgresTest.DataReceivers; -using EntityInjector.Samples.PostgresTest.Models; +using EntityInjector.Samples.PostgresTest.Models.Entities; using EntityInjector.Samples.PostgresTest.Setup; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -12,14 +13,16 @@ using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Swashbuckle.AspNetCore.SwaggerGen; using Xunit; -namespace EntityInjector.Samples.PostgresTest.Tests; +namespace EntityInjector.Samples.PostgresTest.Tests.Route; public class MultipleModelsTests : IClassFixture { private readonly HttpClient _client; private readonly PostgresTestFixture _fixture; + private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true @@ -48,6 +51,10 @@ public MultipleModelsTests(PostgresTestFixture fixture) options.ModelMetadataDetailsProviders.Add(new IntEntityBindingMetadataProvider()); options.ModelMetadataDetailsProviders.Add(new IntCollectionBindingMetadataProvider()); }); + services.PostConfigureAll(o => + { + o.OperationFilter(); + }); }) .Configure(app => { diff --git a/EntityInjector.Samples.PostgresTest/Tests/StringExceptionTests.cs b/EntityInjector.Samples.PostgresTest/Tests/Route/StringExceptionTests.cs similarity index 85% rename from EntityInjector.Samples.PostgresTest/Tests/StringExceptionTests.cs rename to EntityInjector.Samples.PostgresTest/Tests/Route/StringExceptionTests.cs index 5555e6b..626aac8 100644 --- a/EntityInjector.Samples.PostgresTest/Tests/StringExceptionTests.cs +++ b/EntityInjector.Samples.PostgresTest/Tests/Route/StringExceptionTests.cs @@ -1,12 +1,13 @@ 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.Core.Exceptions; +using EntityInjector.Core.Exceptions.Middleware; +using EntityInjector.Core.Interfaces; +using EntityInjector.Route.BindingMetadata.Collection; +using EntityInjector.Route.BindingMetadata.Entity; +using EntityInjector.Route.Filters; using EntityInjector.Samples.PostgresTest.DataReceivers; -using EntityInjector.Samples.PostgresTest.Models; +using EntityInjector.Samples.PostgresTest.Models.Entities; using EntityInjector.Samples.PostgresTest.Setup; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -14,26 +15,28 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using Swashbuckle.AspNetCore.SwaggerGen; using Xunit; -namespace EntityInjector.Samples.PostgresTest.Tests; +namespace EntityInjector.Samples.PostgresTest.Tests.Route; 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(); + services.AddEntityBinding(); // Use only one type of FromRoute bindings per Value type to avoid ambiguous bindings services.AddScoped, StringUserDataReceiver>(); @@ -45,15 +48,19 @@ public StringExceptionTests(PostgresTestFixture fixture) // 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()); }); + services.PostConfigureAll(o => + { + o.OperationFilter(); + }); }) .Configure(app => { app.UseRouting(); - app.UseRouteBinding(); + app.UseEntityBinding(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); }); @@ -79,14 +86,14 @@ public async Task ReturnsNotFoundForNonexistentUserId() var body = await response.Content.ReadAsStringAsync(); var problem = JsonSerializer.Deserialize(body, _jsonOptions); - var expected = new RouteEntityNotFoundException("User", nonexistentUserId); - + var expected = new EntityNotFoundException("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() { @@ -103,15 +110,15 @@ public async Task ReturnsBadRequestWhenRouteParameterIsMissing() var body = await response.Content.ReadAsStringAsync(); var problem = JsonSerializer.Deserialize(body, _jsonOptions); - var expected = new MissingRouteParameterException("id"); - + var expected = new MissingEntityParameterException("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() { @@ -135,7 +142,7 @@ public async Task ReturnsInternalServerErrorWhenNoReceiverIsRegistered() Assert.Equal(expected.Message, problem.Detail); Assert.Equal(requestUri, problem.Instance); } - + [Fact] public async Task ReturnsBadRequestWhenRouteCollectionParameterIsEmpty() { @@ -146,7 +153,7 @@ public async Task ReturnsBadRequestWhenRouteCollectionParameterIsEmpty() var body = await response.Content.ReadAsStringAsync(); var problem = JsonSerializer.Deserialize(body, _jsonOptions); - var expected = new EmptyRouteSegmentListException("ids"); + var expected = new EmptyEntitySegmentListException("ids"); Assert.NotNull(problem); Assert.Equal(expected.StatusCode, problem!.Status); diff --git a/EntityInjector.Samples.PostgresTest/Tests/StringKeyTests.cs b/EntityInjector.Samples.PostgresTest/Tests/Route/StringKeyTests.cs similarity index 87% rename from EntityInjector.Samples.PostgresTest/Tests/StringKeyTests.cs rename to EntityInjector.Samples.PostgresTest/Tests/Route/StringKeyTests.cs index 4518a27..f356e2e 100644 --- a/EntityInjector.Samples.PostgresTest/Tests/StringKeyTests.cs +++ b/EntityInjector.Samples.PostgresTest/Tests/Route/StringKeyTests.cs @@ -1,9 +1,10 @@ using System.Text.Json; -using EntityInjector.Route.Interfaces; -using EntityInjector.Route.Middleware.BindingMetadata.Collection; -using EntityInjector.Route.Middleware.BindingMetadata.Entity; +using EntityInjector.Core.Interfaces; +using EntityInjector.Route.BindingMetadata.Collection; +using EntityInjector.Route.BindingMetadata.Entity; +using EntityInjector.Route.Filters; using EntityInjector.Samples.PostgresTest.DataReceivers; -using EntityInjector.Samples.PostgresTest.Models; +using EntityInjector.Samples.PostgresTest.Models.Entities; using EntityInjector.Samples.PostgresTest.Setup; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -12,19 +13,21 @@ using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Swashbuckle.AspNetCore.SwaggerGen; using Xunit; -namespace EntityInjector.Samples.PostgresTest.Tests; +namespace EntityInjector.Samples.PostgresTest.Tests.Route; 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() @@ -44,6 +47,10 @@ public StringKeyTests(PostgresTestFixture fixture) options.ModelMetadataDetailsProviders.Add(new StringEntityBindingMetadataProvider()); options.ModelMetadataDetailsProviders.Add(new StringCollectionBindingMetadataProvider()); }); + services.PostConfigureAll(o => + { + o.OperationFilter(); + }); }) .Configure(app => { diff --git a/EntityInjector.sln b/EntityInjector.sln index f9c109c..de5ef93 100644 --- a/EntityInjector.sln +++ b/EntityInjector.sln @@ -15,6 +15,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityInjector.Samples.Post EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityInjector.Samples.CosmosTest", "EntityInjector.Samples.CosmosTest\EntityInjector.Samples.CosmosTest.csproj", "{D55F0082-CF85-4511-B006-884EDA045863}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityInjector.Property", "EntityInjector.Property\EntityInjector.Property.csproj", "{9FBEEA0B-DFF7-466C-80C9-F74A75FB2C08}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityInjector.Core", "EntityInjector.Core\EntityInjector.Core.csproj", "{FFAC6CC6-FF2C-4514-A1E5-056901CAA1D5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -33,5 +37,13 @@ Global {D55F0082-CF85-4511-B006-884EDA045863}.Debug|Any CPU.Build.0 = Debug|Any CPU {D55F0082-CF85-4511-B006-884EDA045863}.Release|Any CPU.ActiveCfg = Release|Any CPU {D55F0082-CF85-4511-B006-884EDA045863}.Release|Any CPU.Build.0 = Release|Any CPU + {9FBEEA0B-DFF7-466C-80C9-F74A75FB2C08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9FBEEA0B-DFF7-466C-80C9-F74A75FB2C08}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9FBEEA0B-DFF7-466C-80C9-F74A75FB2C08}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9FBEEA0B-DFF7-466C-80C9-F74A75FB2C08}.Release|Any CPU.Build.0 = Release|Any CPU + {FFAC6CC6-FF2C-4514-A1E5-056901CAA1D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FFAC6CC6-FF2C-4514-A1E5-056901CAA1D5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FFAC6CC6-FF2C-4514-A1E5-056901CAA1D5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FFAC6CC6-FF2C-4514-A1E5-056901CAA1D5}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 5ed7aeb..e503e31 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ in a clean and dependency-injected way. ## Packages | Package | NuGet | Description | -| -------------------- | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | +|----------------------|----------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------| | EntityInjector.Route | [![NuGet](https://img.shields.io/nuget/v/EntityInjector.Route)](https://www.nuget.org/packages/EntityInjector.Route) | Bind route parameters directly to database entities | ## Samples diff --git a/docker-compose.yaml b/docker-compose.yaml index b4f6625..93c9979 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -12,7 +12,7 @@ volumes: - ./init.sql:/docker-entrypoint-initdb.d/init.sql healthcheck: - test: ["CMD", "pg_isready", "-U", "postgres"] + test: [ "CMD", "pg_isready", "-U", "postgres" ] interval: 5s timeout: 5s retries: 12 @@ -25,7 +25,7 @@ ports: - "8081:8081" - "1234:1234" - command: ["--protocol", "https"] + command: [ "--protocol", "https" ] environment: AZURE_COSMOS_EMULATOR_PARTITION_COUNT: 3 AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE: "true" diff --git a/init.sql b/init.sql index 3154920..84aef2e 100644 --- a/init.sql +++ b/init.sql @@ -41,7 +41,8 @@ CREATE TABLE IF NOT EXISTS products -- Insert sample data INSERT INTO users (name, age) VALUES ('Alice', 20), - ('Bob', 18); + ('Bob', 18), + ('Carol', 25); INSERT INTO products (name, price) VALUES ('Standard Widget', 9.99),