From 3b4b3fd1e74b12b102a200ff2bccf60c33013aa9 Mon Sep 17 00:00:00 2001 From: Callum Macpherson <88662046+LBHCallumM@users.noreply.github.com> Date: Thu, 11 Jan 2024 09:05:38 +0000 Subject: [PATCH] Tech Debt - move entity updater to lbh core (#59) * Add EntityUpdater code * Add BodyRewind middleware --- .../EntityUpdater/EntityUpdater.cs | 129 ++++++++++++++++++ .../Interfaces/IEntityUpdater.cs | 47 +++++++ .../EntityUpdater/StringExtensions.cs | 20 +++ .../EntityUpdater/UpdateEntityResult.cs | 31 +++++ .../Hackney.Core.Middleware/BodyRewind.cs | 45 ++++++ 5 files changed, 272 insertions(+) create mode 100644 Hackney.Core/Hackney.Core.DynamoDb/EntityUpdater/EntityUpdater.cs create mode 100644 Hackney.Core/Hackney.Core.DynamoDb/EntityUpdater/Interfaces/IEntityUpdater.cs create mode 100644 Hackney.Core/Hackney.Core.DynamoDb/EntityUpdater/StringExtensions.cs create mode 100644 Hackney.Core/Hackney.Core.DynamoDb/EntityUpdater/UpdateEntityResult.cs create mode 100644 Hackney.Core/Hackney.Core.Middleware/BodyRewind.cs diff --git a/Hackney.Core/Hackney.Core.DynamoDb/EntityUpdater/EntityUpdater.cs b/Hackney.Core/Hackney.Core.DynamoDb/EntityUpdater/EntityUpdater.cs new file mode 100644 index 0000000..869c163 --- /dev/null +++ b/Hackney.Core/Hackney.Core.DynamoDb/EntityUpdater/EntityUpdater.cs @@ -0,0 +1,129 @@ +using Hackney.Core.DynamoDb.EntityUpdater.Interfaces; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Hackney.Core.DynamoDb.EntityUpdater +{ + public class EntityUpdater : IEntityUpdater + { + private readonly ILogger _logger; + + public EntityUpdater(ILogger logger) + { + _logger = logger; + } + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + options.Converters.Add(new JsonStringEnumConverter()); + return options; + } + + private static bool HasValueChanged(object existingValue, object updateValue) + { + if (updateValue is null && existingValue is null) return false; + if (updateValue is null && existingValue != null) return true; + return !updateValue.Equals(existingValue); + } + + /// + /// Updates the supplied entity with the updated property values described in the request object / json + /// Defaults the ignoreUnchangedProperties input value to true. + /// + /// The entity type + /// The type of the update request object + /// The entity to update + /// The raw update request json from which the request object was deserialized + /// The update request object + /// A response object + public UpdateEntityResult UpdateEntity(TEntity entityToUpdate, + string updateJson, + TUpdateObject updateObject) + where TEntity : class + where TUpdateObject : class + { + return UpdateEntity(entityToUpdate, updateJson, updateObject, true); + } + + /// + /// Updates the supplied entity with the updated property values described in the request object / json. + /// * This method expects both a request object and the raw request json so that the appropriate request object validation + /// can be executed by the MVC pipeline. + /// * The inclusion of the request object also means that each updated property value has been deserialised correctly. + /// * The raw request json should contain ONLY the properties to be updated. + /// * The property names in the json / request object MUST MATCH the corresponing properties on the entity type (assuming the json uses camel casing). + /// * For nested objects, those classes must override the Equals() mewthod so that the algorithm will correctly determine if a suboject has changed. + /// + /// The entity type + /// The type of the update request object + /// The entity to update + /// The raw update request json from which the request object was deserialized + /// The update request object + /// Whether or not to ignore property values set in the update request + /// but that are actually the same as current entity value. + /// A response object + public UpdateEntityResult UpdateEntity(TEntity entityToUpdate, + string updateJson, + TUpdateObject updateObject, + bool ignoreUnchangedProperties) + where TEntity : class + where TUpdateObject : class + { + if (entityToUpdate is null) throw new ArgumentNullException(nameof(entityToUpdate)); + if (updateObject is null) throw new ArgumentNullException(nameof(updateObject)); + + var result = new UpdateEntityResult() { UpdatedEntity = entityToUpdate }; + if (string.IsNullOrEmpty(updateJson)) return result; + + var updateDic = JsonSerializer.Deserialize>(updateJson, CreateJsonOptions()); + var entityType = typeof(TEntity); + var updateObjectType = typeof(TUpdateObject); + + var allEntityProperties = entityType.GetProperties(); + foreach (var propName in updateDic.Keys) + { + var prop = allEntityProperties.FirstOrDefault(x => x.Name.ToCamelCase() == propName); + if (prop is null) + { + // Received a property on the request Json that's not on the entity at all + // So we log a warning, ignore it and carry on. + _logger.LogWarning($"Entity object (type: {entityType.Name}) does not contain a property called {propName}. Ignoring {propName} value..."); + result.IgnoredProperties.Add(propName); + continue; + } + + var requestObjectProperty = updateObjectType.GetProperty(prop.Name); + if (requestObjectProperty is null) + { + // Received a property on the request Json we weren't expecting (it's not on the request object) + // So we log a warning, ignore it and carry on. + _logger.LogWarning($"Request object (type: {updateObjectType.Name}) does not contain a property called {prop.Name} that is on the entity type ({entityType.Name}). Ignoring {prop.Name} value..."); + result.IgnoredProperties.Add(propName); + continue; + } + + var updateValue = requestObjectProperty.GetValue(updateObject); + var existingValue = prop.GetValue(entityToUpdate); + + // For sub-objects this Equals() check will only work if the Equals() method is overridden + if (!ignoreUnchangedProperties || HasValueChanged(existingValue, updateValue)) + { + result.OldValues.Add(propName, existingValue); + result.NewValues.Add(propName, updateValue); + prop.SetValue(entityToUpdate, updateValue); + } + } + + return result; + } + } +} diff --git a/Hackney.Core/Hackney.Core.DynamoDb/EntityUpdater/Interfaces/IEntityUpdater.cs b/Hackney.Core/Hackney.Core.DynamoDb/EntityUpdater/Interfaces/IEntityUpdater.cs new file mode 100644 index 0000000..cd3fbc0 --- /dev/null +++ b/Hackney.Core/Hackney.Core.DynamoDb/EntityUpdater/Interfaces/IEntityUpdater.cs @@ -0,0 +1,47 @@ +namespace Hackney.Core.DynamoDb.EntityUpdater.Interfaces +{ + /// + /// Interface describing generic methods for updating an instance of an entity from the suypplied request object and raw request json + /// + public interface IEntityUpdater + { + /// + /// Updates the supplied entity with the updated property values described in the request object / json + /// + /// The entity type + /// The type of the update request object + /// The entity to update + /// The raw update request json from which the request object was deserialized + /// The update request object + /// A response object + UpdateEntityResult UpdateEntity( + TEntity entityToUpdate, + string updateJson, + TUpdateObject updateObject) + where TEntity : class + where TUpdateObject : class; + + /// + /// Updates the supplied entity with the updated property values described in the request object / json. + /// * This method expects both a request object and the raw request json so that the appropriate request object validation + /// can be executed by the MVC pipeline. + /// * The raw request json should contain ONLY the properties to be updated. + /// * The property names in the json / request object MUST MATCH the corresponing properties on the entity type (assuming the json uses camel casing). + /// + /// The entity type + /// The type of the update request object + /// The entity to update + /// The raw update request json from which the request object was deserialized + /// The update request object + /// Whether or not to ignore property values set in the update request + /// but that are actually the same as current entity value. + /// A response object + UpdateEntityResult UpdateEntity( + TEntity entityToUpdate, + string updateJson, + TUpdateObject updateObject, + bool ignoreUnchangedProperties) + where TEntity : class + where TUpdateObject : class; + } +} diff --git a/Hackney.Core/Hackney.Core.DynamoDb/EntityUpdater/StringExtensions.cs b/Hackney.Core/Hackney.Core.DynamoDb/EntityUpdater/StringExtensions.cs new file mode 100644 index 0000000..d7c9272 --- /dev/null +++ b/Hackney.Core/Hackney.Core.DynamoDb/EntityUpdater/StringExtensions.cs @@ -0,0 +1,20 @@ +namespace Hackney.Core.DynamoDb.EntityUpdater +{ + public static class StringExtensions + { + /// + /// Converts the string to camel case (i.e. the first character is lowercase) + /// + /// The string to change + /// A copied of the string with the first chacater in lowercase. A null or empty string returns what was supplied. + /// + public static string ToCamelCase(this string str) + { + if (string.IsNullOrEmpty(str)) return str; + if (str.Length == 1) return str.ToLowerInvariant(); + + // else if (str.Length > 1) + return char.ToLowerInvariant(str[0]) + str.Substring(1); + } + } +} diff --git a/Hackney.Core/Hackney.Core.DynamoDb/EntityUpdater/UpdateEntityResult.cs b/Hackney.Core/Hackney.Core.DynamoDb/EntityUpdater/UpdateEntityResult.cs new file mode 100644 index 0000000..db90e63 --- /dev/null +++ b/Hackney.Core/Hackney.Core.DynamoDb/EntityUpdater/UpdateEntityResult.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace Hackney.Core.DynamoDb.EntityUpdater +{ + public class UpdateEntityResult where T : class + { + /// + /// The updated entity + /// + public T UpdatedEntity { get; set; } + + /// + /// A simple dictionary listing the previous value(s) of any entity properties actually updated by the call. + /// The size and keys of this dictionary will match that of the NewValues property. + /// + public Dictionary OldValues { get; set; } = new Dictionary(); + + /// + /// A simple dictionary listing the new value(s) of any entity properties actually updated by the call. + /// The size and keys of this dictionary will match that of the OldValues property. + /// + public Dictionary NewValues { get; set; } = new Dictionary(); + + /// + /// A collection of properties that were in the orignal request json but that were ignored because either + /// * They did not exist on the request object, or + /// * They did not exist on the entity at all. + /// + public List IgnoredProperties { get; set; } = new List(); + } +} diff --git a/Hackney.Core/Hackney.Core.Middleware/BodyRewind.cs b/Hackney.Core/Hackney.Core.Middleware/BodyRewind.cs new file mode 100644 index 0000000..544f129 --- /dev/null +++ b/Hackney.Core/Hackney.Core.Middleware/BodyRewind.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Threading.Tasks; + +namespace Hackney.Core.Middleware +{ + [ExcludeFromCodeCoverage] + public sealed class BodyRewindMiddleware + { + private readonly RequestDelegate _next; + + public BodyRewindMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task Invoke(HttpContext context) + { + context.Request.EnableBuffering(); + await _next(context).ConfigureAwait(false); + } + } + + [ExcludeFromCodeCoverage] + public static class BodyRewindExtensions + { + /// + /// Enables the HttpRequset body to be access from within a controller method. + /// Without this the request body is rendered unaccessible by other middleware steps. + /// + /// An App builder + /// An App builder + public static IApplicationBuilder EnableRequestBodyRewind(this IApplicationBuilder app) + { + if (app == null) throw new ArgumentNullException(nameof(app)); + + return app.UseMiddleware(); + } + + } +}