Skip to content

Commit

Permalink
Tech Debt - move entity updater to lbh core (#59)
Browse files Browse the repository at this point in the history
* Add EntityUpdater code

* Add BodyRewind middleware
  • Loading branch information
LBHCallumM authored Jan 11, 2024
1 parent 4b8e030 commit 3b4b3fd
Show file tree
Hide file tree
Showing 5 changed files with 272 additions and 0 deletions.
129 changes: 129 additions & 0 deletions Hackney.Core/Hackney.Core.DynamoDb/EntityUpdater/EntityUpdater.cs
Original file line number Diff line number Diff line change
@@ -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<EntityUpdater> _logger;

public EntityUpdater(ILogger<EntityUpdater> 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);
}

/// <summary>
/// Updates the supplied entity with the updated property values described in the request object / json
/// Defaults the ignoreUnchangedProperties input value to true.
/// </summary>
/// <typeparam name="TEntity">The entity type</typeparam>
/// <typeparam name="TUpdateObject">The type of the update request object</typeparam>
/// <param name="entityToUpdate">The entity to update</param>
/// <param name="updateJson">The raw update request json from which the request object was deserialized</param>
/// <param name="updateObject">The update request object</param>
/// <returns>A response object</returns>
public UpdateEntityResult<TEntity> UpdateEntity<TEntity, TUpdateObject>(TEntity entityToUpdate,
string updateJson,
TUpdateObject updateObject)
where TEntity : class
where TUpdateObject : class
{
return UpdateEntity(entityToUpdate, updateJson, updateObject, true);
}

/// <summary>
/// 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.
/// </summary>
/// <typeparam name="TEntity">The entity type</typeparam>
/// <typeparam name="TUpdateObject">The type of the update request object</typeparam>
/// <param name="entityToUpdate">The entity to update</param>
/// <param name="updateJson">The raw update request json from which the request object was deserialized</param>
/// <param name="updateObject">The update request object</param>
/// <param name="ignoreUnchangedProperties">Whether or not to ignore property values set in the update request
/// but that are actually the same as current entity value.</param>
/// <returns>A response object</returns>
public UpdateEntityResult<TEntity> UpdateEntity<TEntity, TUpdateObject>(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<TEntity>() { UpdatedEntity = entityToUpdate };
if (string.IsNullOrEmpty(updateJson)) return result;

var updateDic = JsonSerializer.Deserialize<Dictionary<string, object>>(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;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
namespace Hackney.Core.DynamoDb.EntityUpdater.Interfaces
{
/// <summary>
/// Interface describing generic methods for updating an instance of an entity from the suypplied request object and raw request json
/// </summary>
public interface IEntityUpdater
{
/// <summary>
/// Updates the supplied entity with the updated property values described in the request object / json
/// </summary>
/// <typeparam name="TEntity">The entity type</typeparam>
/// <typeparam name="TUpdateObject">The type of the update request object</typeparam>
/// <param name="entityToUpdate">The entity to update</param>
/// <param name="updateJson">The raw update request json from which the request object was deserialized</param>
/// <param name="updateObject">The update request object</param>
/// <returns>A response object</returns>
UpdateEntityResult<TEntity> UpdateEntity<TEntity, TUpdateObject>(
TEntity entityToUpdate,
string updateJson,
TUpdateObject updateObject)
where TEntity : class
where TUpdateObject : class;

/// <summary>
/// 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).
/// </summary>
/// <typeparam name="TEntity">The entity type</typeparam>
/// <typeparam name="TUpdateObject">The type of the update request object</typeparam>
/// <param name="entityToUpdate">The entity to update</param>
/// <param name="updateJson">The raw update request json from which the request object was deserialized</param>
/// <param name="updateObject">The update request object</param>
/// <param name="ignoreUnchangedProperties">Whether or not to ignore property values set in the update request
/// but that are actually the same as current entity value.</param>
/// <returns>A response object</returns>
UpdateEntityResult<TEntity> UpdateEntity<TEntity, TUpdateObject>(
TEntity entityToUpdate,
string updateJson,
TUpdateObject updateObject,
bool ignoreUnchangedProperties)
where TEntity : class
where TUpdateObject : class;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace Hackney.Core.DynamoDb.EntityUpdater
{
public static class StringExtensions
{
/// <summary>
/// Converts the string to camel case (i.e. the first character is lowercase)
/// </summary>
/// <param name="str">The string to change</param>
/// <returns>A copied of the string with the first chacater in lowercase. A null or empty string returns what was supplied.
/// </returns>
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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Collections.Generic;

namespace Hackney.Core.DynamoDb.EntityUpdater
{
public class UpdateEntityResult<T> where T : class
{
/// <summary>
/// The updated entity
/// </summary>
public T UpdatedEntity { get; set; }

/// <summary>
/// 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.
/// </summary>
public Dictionary<string, object> OldValues { get; set; } = new Dictionary<string, object>();

/// <summary>
/// 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.
/// </summary>
public Dictionary<string, object> NewValues { get; set; } = new Dictionary<string, object>();

/// <summary>
/// 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.
/// </summary>
public List<string> IgnoredProperties { get; set; } = new List<string>();
}
}
45 changes: 45 additions & 0 deletions Hackney.Core/Hackney.Core.Middleware/BodyRewind.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Enables the HttpRequset body to be access from within a controller method.
/// Without this the request body is rendered unaccessible by other middleware steps.
/// </summary>
/// <param name="app">An App builder</param>
/// <returns>An App builder</returns>
public static IApplicationBuilder EnableRequestBodyRewind(this IApplicationBuilder app)
{
if (app == null) throw new ArgumentNullException(nameof(app));

return app.UseMiddleware<BodyRewindMiddleware>();
}

}
}

0 comments on commit 3b4b3fd

Please sign in to comment.