diff --git a/README.md b/README.md index 66235ed..7bae640 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ dotnet add package SimplyWorks.PrimitiveTypes - 🎯 **API Handlers** - CQRS interfaces for commands and queries - 🔧 **Extensions** - Type conversion and utility methods - ☁️ **Cloud Storage** - File operation abstractions +- 🏷️ **Contract labels** - `[Contract("orders.msg.placed", Major = 2)]` names the published shape of a type and its major version, for libraries and schema tools to read ## � Usage Example diff --git a/SW.PrimitiveTypes.UnitTests/ContractAttributeTests.cs b/SW.PrimitiveTypes.UnitTests/ContractAttributeTests.cs new file mode 100644 index 0000000..d889178 --- /dev/null +++ b/SW.PrimitiveTypes.UnitTests/ContractAttributeTests.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace SW.PrimitiveTypes.UnitTests +{ + [TestClass] + public class ContractAttributeTests + { + [Contract("orders.msg.placed", Major = 2)] + class OrderPlaced { } + + class OrderPlacedWithGift : OrderPlaced { } + + [Contract("orders.document.invoice")] + struct Invoice { } + + [TestMethod] + public void Reads_key_and_major() + { + var c = ContractAttribute.Of(typeof(OrderPlaced)); + Assert.AreEqual("orders.msg.placed", c.Key); + Assert.AreEqual(2, c.Major); + Assert.AreEqual("orders.msg.placed@2", c.Id); + Assert.AreEqual("orders.msg.placed@2", c.ToString()); + } + + [TestMethod] + public void Major_defaults_to_1() => + Assert.AreEqual("orders.document.invoice@1", ContractAttribute.Of(typeof(Invoice)).Id); + + [TestMethod] + public void A_derived_type_does_not_take_its_base_contract() + { + Assert.IsNull(ContractAttribute.Of(typeof(OrderPlacedWithGift))); + Assert.IsNull(Attribute.GetCustomAttribute(typeof(OrderPlacedWithGift), typeof(ContractAttribute), inherit: true)); + } + + [TestMethod] + public void An_unlabelled_type_or_null_has_none() + { + Assert.IsNull(ContractAttribute.Of(typeof(string))); + Assert.IsNull(ContractAttribute.Of(null)); + } + + [DataTestMethod] + [DataRow("orders.msg.placed", 1, true)] + [DataRow("orders", 3, true)] + [DataRow("orders2.v2", 1, true)] + [DataRow("Orders.msg.placed", 1, false)] + [DataRow("orders..placed", 1, false)] + [DataRow("orders.msg.", 1, false)] + [DataRow("orders-msg", 1, false)] + [DataRow("orders.msg.placed", 0, false)] + [DataRow("orders\n", 1, false)] + [DataRow("orders.msg.placed\n", 1, false)] + [DataRow(" orders", 1, false)] + public void Checks_its_form(string key, int major, bool wellFormed) => + Assert.AreEqual(wellFormed, new ContractAttribute(key) { Major = major }.IsWellFormed); + + [TestMethod] + public void A_missing_key_is_refused() + { + Assert.ThrowsException(() => new ContractAttribute(null)); + Assert.ThrowsException(() => new ContractAttribute(" ")); + } + } +} diff --git a/SW.PrimitiveTypes/Contracts/ContractAttribute.cs b/SW.PrimitiveTypes/Contracts/ContractAttribute.cs new file mode 100644 index 0000000..caa9568 --- /dev/null +++ b/SW.PrimitiveTypes/Contracts/ContractAttribute.cs @@ -0,0 +1,47 @@ +using System; +using System.Reflection; +using System.Text.RegularExpressions; + +namespace SW.PrimitiveTypes +{ + /// + /// Names the contract a type's shape is published as: a stable key and a major version that changes + /// only on a breaking change. It's a label and does nothing by itself; libraries that move the type + /// (a bus publisher, an API host, an adapter runner) and schema tools read it. + /// + /// + /// Not inherited: a derived type has a shape of its own, so it names its own contract. + /// The constructor rejects only a missing key, because an attribute constructor that throws breaks + /// every caller that reflects over the type. Use to check the rest. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] + public class ContractAttribute : Attribute + { + // \z, not $: in .NET "$" also matches before a final newline, which would let "orders\n" through. + static readonly Regex KeyFormat = new Regex("^[a-z0-9]+(\\.[a-z0-9]+)*\\z", RegexOptions.CultureInvariant); + + public ContractAttribute(string key) + { + if (string.IsNullOrWhiteSpace(key)) throw new ArgumentNullException(nameof(key)); + Key = key; + } + + /// Lowercase, dot-separated: "orders.msg.placed". + public string Key { get; } + + /// Changes only when the shape changes in a way old readers can't handle. + public int Major { get; set; } = 1; + + /// "orders.msg.placed@1". + public string Id => $"{Key}@{Major}"; + + /// The key is lowercase and dot-separated, and the major is at least 1. + public bool IsWellFormed => KeyFormat.IsMatch(Key) && Major >= 1; + + /// The contract declared on the type itself, or null. + public static ContractAttribute Of(Type type) => + type?.GetCustomAttribute(inherit: false); + + public override string ToString() => Id; + } +}