From 3c6e3c42cb16d987423f6d8d142d2c1e2cc65142 Mon Sep 17 00:00:00 2001 From: Muhannad Al-Khatib Date: Fri, 30 Oct 2026 12:33:27 +0300 Subject: [PATCH 1/2] Add ContractAttribute: names a type's published shape and its major version A label only: libraries that move a type (a bus publisher, an API host, an adapter runner) and schema tools can read it. Not inherited, so a derived type names its own contract. The constructor rejects only a missing key; IsWellFormed checks the rest, because an attribute constructor that throws breaks every caller that reflects over the type. --- README.md | 1 + .../ContractAttributeTests.cs | 64 +++++++++++++++++++ .../Contracts/ContractAttribute.cs | 46 +++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 SW.PrimitiveTypes.UnitTests/ContractAttributeTests.cs create mode 100644 SW.PrimitiveTypes/Contracts/ContractAttribute.cs 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..4b3e43d --- /dev/null +++ b/SW.PrimitiveTypes.UnitTests/ContractAttributeTests.cs @@ -0,0 +1,64 @@ +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)] + 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..abdfe5a --- /dev/null +++ b/SW.PrimitiveTypes/Contracts/ContractAttribute.cs @@ -0,0 +1,46 @@ +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 + { + static readonly Regex KeyFormat = new Regex("^[a-z0-9]+(\\.[a-z0-9]+)*$", 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; + } +} From ca3e2ecf3f3139f6a727e3c49832d3d4ff0e964c Mon Sep 17 00:00:00 2001 From: Muhannad Al-Khatib Date: Fri, 30 Oct 2026 12:47:08 +0300 Subject: [PATCH 2/2] Key format: \z instead of $, so a trailing newline doesn't pass In .NET "$" also matches before a final newline, so "orders\n" was well-formed and its Id carried a newline. Found by CodeRabbit. --- SW.PrimitiveTypes.UnitTests/ContractAttributeTests.cs | 3 +++ SW.PrimitiveTypes/Contracts/ContractAttribute.cs | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/SW.PrimitiveTypes.UnitTests/ContractAttributeTests.cs b/SW.PrimitiveTypes.UnitTests/ContractAttributeTests.cs index 4b3e43d..d889178 100644 --- a/SW.PrimitiveTypes.UnitTests/ContractAttributeTests.cs +++ b/SW.PrimitiveTypes.UnitTests/ContractAttributeTests.cs @@ -51,6 +51,9 @@ public void An_unlabelled_type_or_null_has_none() [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); diff --git a/SW.PrimitiveTypes/Contracts/ContractAttribute.cs b/SW.PrimitiveTypes/Contracts/ContractAttribute.cs index abdfe5a..caa9568 100644 --- a/SW.PrimitiveTypes/Contracts/ContractAttribute.cs +++ b/SW.PrimitiveTypes/Contracts/ContractAttribute.cs @@ -17,7 +17,8 @@ namespace SW.PrimitiveTypes [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] public class ContractAttribute : Attribute { - static readonly Regex KeyFormat = new Regex("^[a-z0-9]+(\\.[a-z0-9]+)*$", RegexOptions.CultureInvariant); + // \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) {