From 0f5d1bd2db6be3c0a777a41c36ca6ccd9e6e823d Mon Sep 17 00:00:00 2001 From: Josef Bogad Date: Wed, 8 Jan 2025 13:41:22 +0100 Subject: [PATCH] Add support for MetadataType Attribute (#142) --- README.md | 10 + .../Destructurama.Attributed.approved.txt | 1 + .../MetadataTypeTests.cs | 337 ++++++++++++++++++ .../AttributedDestructuringPolicy.cs | 10 +- .../AttributedDestructuringPolicyOptions.cs | 5 + .../Attributed/CustomPropertyInfoExtension.cs | 39 ++ 6 files changed, 397 insertions(+), 5 deletions(-) create mode 100644 src/Destructurama.Attributed.Tests/MetadataTypeTests.cs create mode 100644 src/Destructurama.Attributed/Attributed/CustomPropertyInfoExtension.cs diff --git a/README.md b/README.md index 09e97ec..2b2c878 100644 --- a/README.md +++ b/README.md @@ -379,6 +379,16 @@ public class WithRegex snippet source | anchor +## 8. Working with MetadataTypeAttribute + +You can apply [`MetadataTypeAttribute`](https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.dataannotations.metadatatypeattribute) to your class providing another type with annotated properties. It may help you in the case when source type with its properties is defined in a code generated file so you don't want to put the attributes in there as they would get overwritten by the generator. Note that you have to set `AttributedDestructuringPolicyOptions.RespectMetadataTypeAttribute` to `true`. + +```csharp +var log = new LoggerConfiguration() + .Destructure.UsingAttributes(x => x.RespectMetadataTypeAttribute = true) + ... +``` + # Benchmarks The results are available [here](https://destructurama.github.io/attributed/dev/bench/). diff --git a/src/Destructurama.Attributed.Tests/Approval/Destructurama.Attributed.approved.txt b/src/Destructurama.Attributed.Tests/Approval/Destructurama.Attributed.approved.txt index 894160f..11c259d 100644 --- a/src/Destructurama.Attributed.Tests/Approval/Destructurama.Attributed.approved.txt +++ b/src/Destructurama.Attributed.Tests/Approval/Destructurama.Attributed.approved.txt @@ -5,6 +5,7 @@ namespace Destructurama.Attributed public AttributedDestructuringPolicyOptions() { } public bool IgnoreNullProperties { get; set; } public bool RespectLogPropertyIgnoreAttribute { get; set; } + public bool RespectMetadataTypeAttribute { get; set; } } public interface IPropertyDestructuringAttribute { diff --git a/src/Destructurama.Attributed.Tests/MetadataTypeTests.cs b/src/Destructurama.Attributed.Tests/MetadataTypeTests.cs new file mode 100644 index 0000000..0495146 --- /dev/null +++ b/src/Destructurama.Attributed.Tests/MetadataTypeTests.cs @@ -0,0 +1,337 @@ +using System.ComponentModel.DataAnnotations; +using Destructurama.Attributed.Tests.Support; +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using Serilog.Events; +using Shouldly; + +namespace Destructurama.Attributed.Tests; + +[TestFixture] +public class MetadataTypeTests +{ + [SetUp] + public void SetUp() + { + AttributedDestructuringPolicy.Clear(); + } + + [TearDown] + public void TearDown() + { + AttributedDestructuringPolicy.Clear(); + } + + [Test] + public void MetadataType_Should_Not_Be_Respected() + { + var customized = new Dto + { + Private = "secret", + Public = "not_Secret" + }; + + var evt = DelegatingSink.Execute(customized); + + var sv = (StructureValue)evt.Properties["Customized"]; + var props = sv.Properties.ToDictionary(p => p.Name, p => p.Value); + + props.Count.ShouldBe(2); + props["Public"].LiteralValue().ShouldBe("not_Secret"); + props["Private"].LiteralValue().ShouldBe("secret"); + } + + [Test] + public void MetadataType_Should_Be_Respected() + { + var customized = new Dto + { + Private = "secret", + Public = "not_Secret" + }; + + var evt = DelegatingSink.Execute(customized, configure: opt => opt.RespectMetadataTypeAttribute = true); + + var sv = (StructureValue)evt.Properties["Customized"]; + var props = sv.Properties.ToDictionary(p => p.Name, p => p.Value); + + props.Count.ShouldBe(1); + props["Public"].LiteralValue().ShouldBe("not_Secret"); + } + + [Test] + public void MetadataTypeWithDerived_Should_Be_Respected() + { + var customized = new DtoWithDerived + { + Private = "secret", + Public = "not_Secret" + }; + + var evt = DelegatingSink.Execute(customized, configure: opt => opt.RespectMetadataTypeAttribute = true); + + var sv = (StructureValue)evt.Properties["Customized"]; + var props = sv.Properties.ToDictionary(p => p.Name, p => p.Value); + + props.Count.ShouldBe(1); + props["Public"].LiteralValue().ShouldBe("not_Secret"); + } + + [Test] + public void WithMask_NotLoggedIfNull_Initialized() + { + var customized = new AttributedWithMask + { + String = "Foo[Masked]", + Object = "Bar[Masked]", + }; + + var evt = DelegatingSink.Execute(customized, configure: x => + { + x.IgnoreNullProperties = true; + x.RespectMetadataTypeAttribute = true; + }); + + var sv = (StructureValue)evt!.Properties["Customized"]; + var props = sv.Properties.ToDictionary(p => p.Name, p => p.Value); + + props.ContainsKey("String").ShouldBeTrue(); + props.ContainsKey("Object").ShouldBeTrue(); + + props["String"].LiteralValue().ShouldBe("Foo***"); + props["Object"].LiteralValue().ShouldBe("Bar***"); + } + + + [Test] + public void AttributesAreConsultedWhenDestructuringWithMeta() + { + var customized = new CustomizedWithMeta + { + ImmutableScalar = new(), + MutableScalar = new(), + NotAScalar = new(), + Ignored = "Hello, there", + Ignored2 = "Hello, there again", + ScalarAnyway = new(), + AuthData = new() + { + Username = "This is a username", + Password = "This is a password" + } + }; + + var evt = DelegatingSink.Execute(customized, configure: opt => + { + opt.RespectLogPropertyIgnoreAttribute = true; + opt.RespectMetadataTypeAttribute = true; + }); + + var sv = (StructureValue)evt.Properties["Customized"]; + var props = sv.Properties.ToDictionary(p => p.Name, p => p.Value); + + props.ShouldNotContainKey("Ignored"); + props.ShouldNotContainKey("Ignored2"); + + props["ImmutableScalar"].LiteralValue().ShouldBeOfType(); + props["MutableScalar"].LiteralValue().ShouldBe(new MutableScalar().ToString()); + props["NotAScalar"].ShouldBeOfType(); + props.ContainsKey("Ignored").ShouldBeFalse(); + props["ScalarAnyway"].LiteralValue().ShouldBeOfType(); + props["Struct1"].LiteralValue().ShouldBeOfType(); + props["Struct2"].LiteralValue().ShouldBeOfType(); + props["StructReturningNull"].LiteralValue().ShouldBeNull(); + props["StructNull"].LiteralValue().ShouldBeNull(); + + var str = sv.ToString(); + str.Contains("This is a username").ShouldBeTrue(); + str.Contains("This is a password").ShouldBeFalse(); + } + + [Test] + public void Private_Property_Should_Be_Handled() + { + var customized = new ClassWithPrivateProperty(); + + var evt = DelegatingSink.Execute(customized); + + var sv = (StructureValue)evt.Properties["Customized"]; + sv.Properties.Count.ShouldBe(0); + } + + #region Simple Metadata + /// + /// Simple Metadata Sample + /// + [MetadataType(typeof(DtoMetadata))] + private partial class Dto + { + public string Private { get; set; } + + public string Public { get; set; } + } + + private class DtoMetadata + { + [NotLogged] + public object Private { get; set; } + } + #endregion + + #region Metadata with derived subclass + /// + /// Metadata Sample with derived subclass + /// + [MetadataType(typeof(DtoMetadataDerived))] + private partial class DtoWithDerived + { + public string Private { get; set; } + + public string Public { get; set; } + } + + private class DtoMetadataBase + { + public object Public { get; set; } + } + + private class DtoMetadataDerived : DtoMetadataBase + { + [NotLogged] + public object Private { get; set; } + } + #endregion + + #region Attributed With Mask in MetadataType + [MetadataType(typeof(AttributedWithMaskMetaData))] + private class AttributedWithMask + { + public string? String { get; set; } + + public object? Object { get; set; } + } + + private class AttributedWithMaskMetaData + { + [LogMasked(ShowFirst = 3)] + public object String { get; set; } + + [LogMasked(ShowFirst = 3)] + public object Object { get; set; } + } + #endregion + + #region All Attributes visited + /// + /// Attribute on class in Metadatatype + /// + [LogAsScalar] + public class ImmutableScalar + { + public ImmutableScalar() + { + } + } + + [LogAsScalar(isMutable: true)] + public class MutableScalar + { + } + + public class NotAScalar + { + } + + [MetadataType(typeof(CustomizedMeta))] + public class CustomizedWithMeta + { + public ImmutableScalar? ImmutableScalar { get; set; } + public MutableScalar? MutableScalar { get; set; } + public NotAScalar? NotAScalar { get; set; } + public string? Ignored { get; set; } + public string? Ignored2 { get; set; } + public NotAScalar? ScalarAnyway { get; set; } + public UserAuthData? AuthData { get; set; } + public Struct1 Struct1 { get; set; } + public Struct2 Struct2 { get; set; } + public StructReturningNull StructReturningNull { get; set; } + public StructReturningNull? StructNull { get; set; } + } + + public class CustomizedMeta + { + public ImmutableScalar? ImmutableScalar { get; set; } + public MutableScalar? MutableScalar { get; set; } + public NotAScalar? NotAScalar { get; set; } + + [NotLogged] + public object Ignored { get; set; } + + [LogPropertyIgnore] + public object Ignored2 { get; set; } + + [LogAsScalar] + public object ScalarAnyway { get; set; } + public UserAuthData? AuthData { get; set; } + + [LogAsScalar] + public object Struct1 { get; set; } + + public object Struct2 { get; set; } + + [LogAsScalar(isMutable: true)] + public object StructReturningNull { get; set; } + + [LogAsScalar(isMutable: true)] + public object StructNull { get; set; } + } + + public class UserAuthDataMeta + { + public object Username { get; set; } + + [NotLogged] + public object Password { get; set; } + } + + [MetadataType(typeof(UserAuthDataMeta))] + public class UserAuthData + { + public string? Username { get; set; } + public string? Password { get; set; } + } + + public struct Struct1 + { + public int SomeProperty { get; set; } + public override string ToString() => "AAA"; + } + + [LogAsScalar] + public struct Struct2 + { + public int SomeProperty { get; set; } + public override string ToString() => "BBB"; + } + + public struct StructReturningNull + { + public int SomeProperty { get; set; } + public override string ToString() => null!; + } + #endregion + + #region Private + public class ClassWithPrivatePropertyMeta + { + [LogMasked] + private object Name { get; set; } + } + + [MetadataType(typeof(ClassWithPrivatePropertyMeta))] + public class ClassWithPrivateProperty + { + private string? Name { get; set; } = "Tom"; + } + #endregion +} diff --git a/src/Destructurama.Attributed/Attributed/AttributedDestructuringPolicy.cs b/src/Destructurama.Attributed/Attributed/AttributedDestructuringPolicy.cs index 6b3edf8..89c2403 100644 --- a/src/Destructurama.Attributed/Attributed/AttributedDestructuringPolicy.cs +++ b/src/Destructurama.Attributed/Attributed/AttributedDestructuringPolicy.cs @@ -47,7 +47,7 @@ public bool TryDestructure(object value, ILogEventPropertyValueFactory propertyV return cached.CanDestructure; } - private static IEnumerable GetPropertiesRecursive(Type type) + private IEnumerable GetPropertiesRecursive(Type type) { var seenNames = new HashSet(); @@ -70,13 +70,13 @@ private CacheEntry CreateCacheEntry(Type type) { IPropertyDestructuringAttribute? GetPropertyDestructuringAttribute(PropertyInfo propertyInfo) { - var attr = propertyInfo.GetCustomAttributes().OfType().FirstOrDefault(); + var attr = propertyInfo.GetCustomAttributesEx(_options.RespectMetadataTypeAttribute).OfType().FirstOrDefault(); if (attr != null) return attr; // Do not check attribute explicitly to not take dependency from Microsoft.Extensions.Telemetry.Abstractions package. // https://github.com/serilog/serilog/issues/1984 - return _options.RespectLogPropertyIgnoreAttribute && propertyInfo.GetCustomAttributes().Any(a => a.GetType().FullName == "Microsoft.Extensions.Logging.LogPropertyIgnoreAttribute") + return _options.RespectLogPropertyIgnoreAttribute && propertyInfo.GetCustomAttributesEx(_options.RespectMetadataTypeAttribute).Any(a => a.GetType().FullName == "Microsoft.Extensions.Logging.LogPropertyIgnoreAttribute") ? NotLoggedAttribute.Instance : null; } @@ -88,13 +88,13 @@ private CacheEntry CreateCacheEntry(Type type) var properties = GetPropertiesRecursive(type).ToList(); if (!_options.IgnoreNullProperties && properties.All(pi => GetPropertyDestructuringAttribute(pi) == null - && pi.GetCustomAttributes().OfType().FirstOrDefault() == null)) + && pi.GetCustomAttributesEx(_options.RespectMetadataTypeAttribute).OfType().FirstOrDefault() == null)) { return CacheEntry.Ignore; } var optionalIgnoreAttributes = properties - .Select(pi => new { pi, Attribute = pi.GetCustomAttributes().OfType().FirstOrDefault() }) + .Select(pi => new { pi, Attribute = pi.GetCustomAttributesEx(_options.RespectMetadataTypeAttribute).OfType().FirstOrDefault() }) .Where(o => o.Attribute != null) .ToDictionary(o => o.pi, o => o.Attribute); diff --git a/src/Destructurama.Attributed/Attributed/AttributedDestructuringPolicyOptions.cs b/src/Destructurama.Attributed/Attributed/AttributedDestructuringPolicyOptions.cs index 4d00e9a..03ddfb9 100644 --- a/src/Destructurama.Attributed/Attributed/AttributedDestructuringPolicyOptions.cs +++ b/src/Destructurama.Attributed/Attributed/AttributedDestructuringPolicyOptions.cs @@ -19,4 +19,9 @@ public class AttributedDestructuringPolicyOptions /// This works the same as when applying to the property but may help if you have no access to it's source code. /// public bool RespectLogPropertyIgnoreAttribute { get; set; } + + /// + /// Respect MetadataTypeAttribute pointing to another class with annotated properties. + /// + public bool RespectMetadataTypeAttribute { get; set; } } diff --git a/src/Destructurama.Attributed/Attributed/CustomPropertyInfoExtension.cs b/src/Destructurama.Attributed/Attributed/CustomPropertyInfoExtension.cs new file mode 100644 index 0000000..c03ae09 --- /dev/null +++ b/src/Destructurama.Attributed/Attributed/CustomPropertyInfoExtension.cs @@ -0,0 +1,39 @@ +using System.Reflection; + +namespace Destructurama.Attributed; + +internal static class CustomPropertyInfoExtension +{ + /// + /// Returns a list of custom attributes from the specified property or from corresponding property from another + /// class if System.ComponentModel.DataAnnotations.MetadataTypeAttribute is used. + /// + public static IEnumerable GetCustomAttributesEx(this PropertyInfo propertyInfo, bool respectMetadata) + { + if (!respectMetadata) + { + return propertyInfo.GetCustomAttributes(); + } + + // Get the type in which property is declared to look whether MetadataTypeAttribute is specified. + // If so, get the class, find the property with same name and if exists, return its custom attributes. + var type = propertyInfo.DeclaringType; + + // Do not check attribute explicitly to not take dependency from System.ComponentModel.Annotations package. + var metadataTypeAttribute = type.GetCustomAttributes(true).Where(t => t.GetType().FullName == "System.ComponentModel.DataAnnotations.MetadataTypeAttribute").FirstOrDefault(); + if (metadataTypeAttribute != null) + { + var metadataType = (Type)metadataTypeAttribute.GetType().GetProperty("MetadataClassType").GetValue(metadataTypeAttribute, null); + var metadataProperty = metadataType.GetProperty(propertyInfo.Name); + + if (metadataProperty != null) + { + return metadataProperty.GetCustomAttributes(); + } + + // Property was not declared in MetadataClassType, fall through and return attributes from original property. + } + + return propertyInfo.GetCustomAttributes(); + } +}