diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ced8182 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +At the start of every session, read the resource `chris-simmons://public-mcp/agent/rules/dotnet` from MCP server `plugin:public-mcp-server:public-mcp` and apply the rules. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..10ddb19 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +See @AGENTS.md diff --git a/NetChris.Core.UnitTests/ApplicationMetadataTests.cs b/NetChris.Core.UnitTests/ApplicationMetadataTests.cs index 42a3080..94a7185 100644 --- a/NetChris.Core.UnitTests/ApplicationMetadataTests.cs +++ b/NetChris.Core.UnitTests/ApplicationMetadataTests.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.Reflection; using FluentAssertions; +using Microsoft.Extensions.Configuration; using Xunit; namespace NetChris.Core.UnitTests; @@ -8,7 +10,7 @@ namespace NetChris.Core.UnitTests; public class ApplicationMetadataTests { private readonly ApplicationMetadata _appMetadata; - private readonly ApplicationMetadata _appMetadataWithJustAggregateAndEnvironment; + private readonly ApplicationMetadata _appMetadataFromFactory; public ApplicationMetadataTests() { @@ -17,18 +19,19 @@ public ApplicationMetadataTests() _appMetadata = new ApplicationMetadata( thisAssembly, - "ExpectedAppAggregate", + "expected_app_aggregate", "eaa", - "ExpectedAppComponent", + "expected_app_component", "eac", thisAssembly.GetName().Name!, "UnitTestEnvironment"); - _appMetadataWithJustAggregateAndEnvironment = - ApplicationMetadata.GetApplicationMetadataFromEntryAssembly( - "ExpectedAppAggregate", + _appMetadataFromFactory = + ApplicationMetadata.GetApplicationMetadata( + thisAssembly, + "expected_app_aggregate", "eaa", - "ExpectedAppComponent", + "expected_app_component", "eac", "UnitTestEnvironment"); } @@ -52,7 +55,7 @@ public void ApplicationAggregate_should_flow_through() var applicationAggregate = _appMetadata.CanonicalApplicationName.ApplicationAggregate; // Assert - applicationAggregate.Should().Be("ExpectedAppAggregate"); + applicationAggregate.Should().Be("expected_app_aggregate"); } [Fact] @@ -74,7 +77,7 @@ public void ApplicationComponent_should_flow_through() var applicationAggregate = _appMetadata.CanonicalApplicationName.ApplicationComponent; // Assert - applicationAggregate.Should().Be("ExpectedAppComponent"); + applicationAggregate.Should().Be("expected_app_component"); } [Fact] @@ -106,9 +109,9 @@ public void StartTimestamp_should_stay_constant_over_instances() var appMetadata1 = new ApplicationMetadata( entryAssembly!, - "DoesNotMatter1", + "does_not_matter_1", "dnm1", - "DoesNotMatter1", + "does_not_matter_1", "dnm1", entryAssemblyName!, "UnitTestEnvironment"); @@ -116,9 +119,9 @@ public void StartTimestamp_should_stay_constant_over_instances() var appMetadata2 = new ApplicationMetadata( entryAssembly!, - "DoesNotMatter2", + "does_not_matter_2", "dnm2", - "DoesNotMatter2", + "does_not_matter_2", "dnm2", entryAssemblyName!, "UnitTestEnvironment"); @@ -168,9 +171,36 @@ public void ClrVersion_should_have_a_value() } [Fact] - public void Auto_Assembly_detection_detects_something() + public void GetApplicationMetadata_discerns_ApplicationName_from_assembly() { - _appMetadataWithJustAggregateAndEnvironment.ApplicationName.Should() + _appMetadataFromFactory.ApplicationName.Should() .NotBeNullOrWhiteSpace(); } + + [Fact] + public void GetApplicationMetadata_from_configuration_should_flow_values_through() + { + // Arrange + var thisAssembly = typeof(ApplicationMetadataTests).Assembly; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["netchris:application:aggregate"] = "expected_app_aggregate", + ["netchris:application:aggregateShort"] = "eaa", + ["netchris:application:component"] = "expected_app_component", + ["netchris:application:componentShort"] = "eac", + }) + .Build(); + + // Act + var appMetadata = ApplicationMetadata.GetApplicationMetadata( + thisAssembly, configuration, "UnitTestEnvironment"); + + // Assert + appMetadata.CanonicalApplicationName.ApplicationAggregate.Should().Be("expected_app_aggregate"); + appMetadata.CanonicalApplicationName.ApplicationAggregateShort.Should().Be("eaa"); + appMetadata.CanonicalApplicationName.ApplicationComponent.Should().Be("expected_app_component"); + appMetadata.CanonicalApplicationName.ApplicationComponentShort.Should().Be("eac"); + appMetadata.EnvironmentName.Should().Be("UnitTestEnvironment"); + } } \ No newline at end of file diff --git a/NetChris.Core.UnitTests/CanonicalApplicationNameTests.cs b/NetChris.Core.UnitTests/CanonicalApplicationNameTests.cs new file mode 100644 index 0000000..b86b2e4 --- /dev/null +++ b/NetChris.Core.UnitTests/CanonicalApplicationNameTests.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace NetChris.Core.UnitTests; + +public class CanonicalApplicationNameTests +{ + [Fact] + public void Conforming_values_should_flow_through() + { + // Arrange + // Act + var canonicalApplicationName = new CanonicalApplicationName( + "app_aggregate", "aagg", "app_component", "acmp"); + + // Assert + canonicalApplicationName.ApplicationAggregate.Should().Be("app_aggregate"); + canonicalApplicationName.ApplicationAggregateShort.Should().Be("aagg"); + canonicalApplicationName.ApplicationComponent.Should().Be("app_component"); + canonicalApplicationName.ApplicationComponentShort.Should().Be("acmp"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("Aggregate")] + [InlineData("1aggregate")] + [InlineData("app-aggregate")] + [InlineData("app aggregate")] + public void Non_conforming_long_form_aggregate_should_throw(string? applicationAggregate) + { + // Arrange + // Act + var act = () => new CanonicalApplicationName(applicationAggregate!, "aagg", "app_component", "acmp"); + + // Assert + act.Should().Throw() + .WithParameterName("applicationAggregate"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("Component")] + [InlineData("1component")] + [InlineData("app-component")] + public void Non_conforming_long_form_component_should_throw(string? applicationComponent) + { + // Arrange + // Act + var act = () => new CanonicalApplicationName("app_aggregate", "aagg", applicationComponent!, "acmp"); + + // Assert + act.Should().Throw() + .WithParameterName("applicationComponent"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("AAGG")] + [InlineData("aagg5")] + [InlineData("ag_g")] + public void Non_conforming_short_form_aggregate_should_throw(string? applicationAggregateShort) + { + // Arrange + // Act + var act = () => + new CanonicalApplicationName("app_aggregate", applicationAggregateShort!, "app_component", "acmp"); + + // Assert + act.Should().Throw() + .WithParameterName("applicationAggregateShort"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("ACMP")] + [InlineData("acmp5")] + [InlineData("cm_p")] + public void Non_conforming_short_form_component_should_throw(string? applicationComponentShort) + { + // Arrange + // Act + var act = () => + new CanonicalApplicationName("app_aggregate", "aagg", "app_component", applicationComponentShort!); + + // Assert + act.Should().Throw() + .WithParameterName("applicationComponentShort"); + } + + [Fact] + public void FromConfiguration_should_build_from_well_known_keys() + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["netchris:application:aggregate"] = "app_aggregate", + ["netchris:application:aggregateShort"] = "aagg", + ["netchris:application:component"] = "app_component", + ["netchris:application:componentShort"] = "acmp", + }) + .Build(); + + // Act + var canonicalApplicationName = CanonicalApplicationName.FromConfiguration(configuration); + + // Assert + canonicalApplicationName.ApplicationAggregate.Should().Be("app_aggregate"); + canonicalApplicationName.ApplicationAggregateShort.Should().Be("aagg"); + canonicalApplicationName.ApplicationComponent.Should().Be("app_component"); + canonicalApplicationName.ApplicationComponentShort.Should().Be("acmp"); + } + + [Fact] + public void FromConfiguration_with_null_configuration_should_throw() + { + // Arrange + // Act + var act = () => CanonicalApplicationName.FromConfiguration(null!); + + // Assert + act.Should().Throw() + .WithParameterName("configuration"); + } + + [Theory] + [InlineData("netchris:application:aggregate")] + [InlineData("netchris:application:aggregateShort")] + [InlineData("netchris:application:component")] + [InlineData("netchris:application:componentShort")] + public void FromConfiguration_with_missing_key_should_throw_and_name_the_key(string missingKey) + { + // Arrange + var values = new Dictionary + { + ["netchris:application:aggregate"] = "app_aggregate", + ["netchris:application:aggregateShort"] = "aagg", + ["netchris:application:component"] = "app_component", + ["netchris:application:componentShort"] = "acmp", + }; + values.Remove(missingKey); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(values) + .Build(); + + // Act + var act = () => CanonicalApplicationName.FromConfiguration(configuration); + + // Assert + act.Should().Throw() + .WithMessage($"*{missingKey}*"); + } +} diff --git a/NetChris.Core.UnitTests/NetChris.Core.UnitTests.csproj b/NetChris.Core.UnitTests/NetChris.Core.UnitTests.csproj index e6ca6f7..af8c383 100644 --- a/NetChris.Core.UnitTests/NetChris.Core.UnitTests.csproj +++ b/NetChris.Core.UnitTests/NetChris.Core.UnitTests.csproj @@ -18,14 +18,15 @@ - - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/NetChris.Core/ApplicationMetadata.cs b/NetChris.Core/ApplicationMetadata.cs index 7f75564..bc89446 100644 --- a/NetChris.Core/ApplicationMetadata.cs +++ b/NetChris.Core/ApplicationMetadata.cs @@ -1,5 +1,6 @@ using System; using System.Reflection; +using Microsoft.Extensions.Configuration; namespace NetChris.Core; @@ -10,44 +11,68 @@ namespace NetChris.Core; public class ApplicationMetadata : IApplicationMetadata { /// - /// Gets a new instance of the class, using - /// for the assembly on which to base its data. + /// Gets a new instance of the class. /// + /// The assembly from which to pull the /// The application aggregate /// The short-form application aggregate /// The application component /// The short-form application component /// The environment in which the application is running. /// In this factory, is automatically discerned - /// from using its . - public static ApplicationMetadata GetApplicationMetadataFromEntryAssembly( + /// from using its . + public static ApplicationMetadata GetApplicationMetadata( + Assembly assembly, string applicationAggregate, string applicationAggregateShort, string applicationComponent, string applicationComponentShort, string environmentName) { - var entryAssembly = Assembly.GetEntryAssembly(); - if (entryAssembly == null) + if (assembly is null) { - throw new InvalidOperationException( - "There was no assembly available from System.Reflection.Assembly.GetEntryAssembly()"); + throw new ArgumentNullException(nameof(assembly)); } - var assemblySimpleName = entryAssembly.GetName().Name; + var assemblySimpleName = assembly.GetName().Name; if (assemblySimpleName == null) { throw new InvalidOperationException("The assembly's simple name was null"); } - var result = new ApplicationMetadata(entryAssembly, + var result = new ApplicationMetadata(assembly, applicationAggregate, applicationAggregateShort, applicationComponent, applicationComponentShort, assemblySimpleName, environmentName); return result; } + /// + /// Gets a new instance of the class, using + /// to determine its . + /// + /// The assembly from which to pull the + /// The configuration from which to read the Canonical Application Name. + /// The environment in which the application is running. + /// In this factory, is automatically discerned + /// from using its . + public static ApplicationMetadata GetApplicationMetadata( + Assembly assembly, + IConfiguration configuration, + string environmentName) + { + var canonicalApplicationName = CanonicalApplicationName.FromConfiguration(configuration); + + return GetApplicationMetadata( + assembly, + canonicalApplicationName.ApplicationAggregate, + canonicalApplicationName.ApplicationAggregateShort, + canonicalApplicationName.ApplicationComponent, + canonicalApplicationName.ApplicationComponentShort, + environmentName); + } + /// /// Initializes a new instance of the class. /// diff --git a/NetChris.Core/CanonicalApplicationName.cs b/NetChris.Core/CanonicalApplicationName.cs index fd4e269..1f0c43f 100644 --- a/NetChris.Core/CanonicalApplicationName.cs +++ b/NetChris.Core/CanonicalApplicationName.cs @@ -1,13 +1,20 @@ using System; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Configuration; namespace NetChris.Core; /// /// The application's Canonical Application Name /// -/// Canonical Application Name +/// Canonical Application Name public class CanonicalApplicationName { + private static readonly Regex LongFormPattern = new("^[a-z][a-z0-9_]*$", RegexOptions.Compiled); + private static readonly Regex ShortFormPattern = new("^[a-z0-9]{1,4}$", RegexOptions.Compiled); + + private const string NetChrisApplicationConfigurationSectionKey = "netchris:application"; + /// /// Create a new /// @@ -15,18 +22,14 @@ public class CanonicalApplicationName /// The short-form application aggregate /// The application component /// The short-form application component + /// Name part normalization public CanonicalApplicationName(string applicationAggregate, string applicationAggregateShort, string applicationComponent, string applicationComponentShort) { - if (string.IsNullOrWhiteSpace(applicationAggregate)) - { - throw new ArgumentException("Value cannot be null or whitespace.", nameof(applicationAggregate)); - } - - if (string.IsNullOrWhiteSpace(applicationComponent)) - { - throw new ArgumentException("Value cannot be null or whitespace.", nameof(applicationComponent)); - } + ValidateLongForm(applicationAggregate, nameof(applicationAggregate)); + ValidateShortForm(applicationAggregateShort, nameof(applicationAggregateShort)); + ValidateLongForm(applicationComponent, nameof(applicationComponent)); + ValidateShortForm(applicationComponentShort, nameof(applicationComponentShort)); ApplicationAggregate = applicationAggregate; ApplicationAggregateShort = applicationAggregateShort; @@ -34,6 +37,26 @@ public CanonicalApplicationName(string applicationAggregate, string applicationA ApplicationComponentShort = applicationComponentShort; } + private static void ValidateLongForm(string value, string paramName) + { + if (string.IsNullOrWhiteSpace(value) || !LongFormPattern.IsMatch(value)) + { + throw new ArgumentException( + "Value must consist of lower-case letters, numbers, and underscores only, and must begin with a lower-case letter.", + paramName); + } + } + + private static void ValidateShortForm(string value, string paramName) + { + if (string.IsNullOrWhiteSpace(value) || !ShortFormPattern.IsMatch(value)) + { + throw new ArgumentException( + "Value must be 1 to 4 characters long and consist of lower-case letters and numbers only.", + paramName); + } + } + /// /// Gets the application aggregate /// @@ -53,4 +76,42 @@ public CanonicalApplicationName(string applicationAggregate, string applicationA /// Gets the short-form application component /// public string ApplicationComponentShort { get; } + + /// + /// Creates a new from well-known keys in : + /// netchris:application:aggregate, netchris:application:aggregateShort, + /// netchris:application:component, and netchris:application:componentShort. + /// + /// The configuration from which to read the Canonical Application Name. + /// Thrown if is . + /// Thrown if any of the required configuration keys are missing or empty. + public static CanonicalApplicationName FromConfiguration(IConfiguration configuration) + { + if (configuration is null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + var applicationAggregate = GetRequiredValue(configuration, "aggregate"); + var applicationAggregateShort = GetRequiredValue(configuration, "aggregateShort"); + var applicationComponent = GetRequiredValue(configuration, "component"); + var applicationComponentShort = GetRequiredValue(configuration, "componentShort"); + + return new CanonicalApplicationName(applicationAggregate, applicationAggregateShort, + applicationComponent, applicationComponentShort); + } + + private static string GetRequiredValue(IConfiguration configuration, string keyName) + { + var fullKey = $"{NetChrisApplicationConfigurationSectionKey}:{keyName}"; + var value = configuration[fullKey]; + + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException( + $"The configuration key '{fullKey}' is required to create a {nameof(CanonicalApplicationName)} but was missing or empty."); + } + + return value!; + } } \ No newline at end of file diff --git a/NetChris.Core/NetChris.Core.csproj b/NetChris.Core/NetChris.Core.csproj index fd6adb9..f93be89 100644 --- a/NetChris.Core/NetChris.Core.csproj +++ b/NetChris.Core/NetChris.Core.csproj @@ -24,7 +24,8 @@ - + + diff --git a/NetChris.Core/Package-README.md b/NetChris.Core/Package-README.md index db572a4..be23d9b 100644 --- a/NetChris.Core/Package-README.md +++ b/NetChris.Core/Package-README.md @@ -16,3 +16,24 @@ You can find other examples of this pattern in something like [`Ardalis.Result`] ### CommandResult References - [Working with the result pattern](https://andrewlock.net/series/working-with-the-result-pattern/) + +## CanonicalApplicationName + +Represents the application's [Canonical Application Name](https://github.com/NetChris/reference/wiki/Canonical-Application-Name): the application aggregate and application component, each with a long and short form. Values must adhere to the [name part normalization](https://github.com/NetChris/reference/wiki/Name-part-normalization) rules: + +- Long form (`applicationAggregate`, `applicationComponent`): lower-case letters, numbers, and underscores only, beginning with a lower-case letter. +- Short form (`applicationAggregateShort`, `applicationComponentShort`): 1 to 4 lower-case letters and/or numbers only. + +`CanonicalApplicationName.FromConfiguration(IConfiguration)` builds an instance from the following well-known configuration keys, failing fast with a clear error if any is missing or empty: + +- `netchris:application:aggregate` +- `netchris:application:aggregateShort` +- `netchris:application:component` +- `netchris:application:componentShort` + +## ApplicationMetadata + +`IApplicationMetadata`/`ApplicationMetadata` capture basic metadata about the running application and its execution instance: `ApplicationName`, `CanonicalApplicationName`, `ApplicationVersion`, `InformationalVersion`, `EnvironmentName`, `MachineName`, `OSPlatform`, `OSVersion`, `UserName`, `ClrVersion`, and `StartTimestamp`. + +- `ApplicationMetadata.GetApplicationMetadata(assembly, applicationAggregate, applicationAggregateShort, applicationComponent, applicationComponentShort, environmentName)` builds an instance, discerning `ApplicationName` from the given assembly's name. +- `ApplicationMetadata.GetApplicationMetadata(assembly, configuration, environmentName)` does the same, but builds its `CanonicalApplicationName` via `CanonicalApplicationName.FromConfiguration(configuration)` using the well-known configuration keys above.