diff --git a/docs/examples/index.md b/docs/examples/index.md index 0169bfe8..19832295 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -87,6 +87,9 @@ Welcome! This section collects small, focused demos that show **how to compose b * **Generated Message Envelope** Shows fluent and source-generated message envelope contracts side by side, with an importable `IServiceCollection` extension. See [Generated Message Envelope](generated-message-envelope.md). +* **Order Canonical Data Model** + Shows fluent and source-generated order normalization into an application-owned canonical contract, with an importable `IServiceCollection` extension. See [Order Canonical Data Model](order-canonical-data-model.md). + * **Generated Recipient List** Shows fluent and source-generated recipient-list fan-out side by side, with an importable `IServiceCollection` extension. See [Generated Recipient List](generated-recipient-list.md). diff --git a/docs/examples/order-canonical-data-model.md b/docs/examples/order-canonical-data-model.md new file mode 100644 index 00000000..48dcf099 --- /dev/null +++ b/docs/examples/order-canonical-data-model.md @@ -0,0 +1,12 @@ +# Order Canonical Data Model + +The order canonical data model example normalizes partner and marketplace order contracts into `CanonicalCommerceOrder`. + +```csharp +services.AddCanonicalOrderDataModelDemo(); + +var service = provider.GetRequiredService(); +var summary = service.ImportPartnerOrder(new PartnerOrderDocument("P-100", 42.50m, "usd")); +``` + +The example includes fluent and source-generated construction plus an importable `IServiceCollection` extension for standard .NET hosts. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 0e287233..789796be 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -79,6 +79,9 @@ - name: Generated Message Translator href: generated-message-translator.md +- name: Order Canonical Data Model + href: order-canonical-data-model.md + - name: Generated Claim Check href: generated-claim-check.md diff --git a/docs/generators/canonical-data-model.md b/docs/generators/canonical-data-model.md new file mode 100644 index 00000000..fa7e00a2 --- /dev/null +++ b/docs/generators/canonical-data-model.md @@ -0,0 +1,20 @@ +# Canonical Data Model Generator + +`[GenerateCanonicalDataModel]` creates a typed `CanonicalDataModel` factory from a source-to-canonical mapper. + +```csharp +[GenerateCanonicalDataModel(typeof(PartnerOrderDocument), typeof(CanonicalCommerceOrder), ModelName = "commerce-orders", AdapterName = "partner-orders")] +public static partial class PartnerOrderCanonicalModel +{ + [CanonicalDataModelMapper] + private static CanonicalCommerceOrder Map(PartnerOrderDocument order) => new(order.ExternalOrderId, order.Amount, "USD"); +} +``` + +The generated factory is parameterless, so applications can register the canonical model directly in `IServiceCollection`. + +Diagnostics: + +- `PKCDM001`: host type must be partial. +- `PKCDM002`: exactly one mapper is required. +- `PKCDM003`: mapper signature is invalid. diff --git a/docs/generators/index.md b/docs/generators/index.md index 0cc175a6..4598b600 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -88,6 +88,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Service Activator**](service-activator.md) | Message-to-service operation factories | `[GenerateServiceActivator]` | | [**Message Envelope**](messaging.md#generated-message-envelope) | Required message metadata contracts | `[GenerateMessageEnvelope]` | | [**Message Translator**](message-translator.md) | Partner and transport event normalization | `[GenerateMessageTranslator]` | +| [**Canonical Data Model**](canonical-data-model.md) | Source-to-canonical contract normalization | `[GenerateCanonicalDataModel]` | | [**Claim Check**](claim-check.md) | External payload storage references | `[GenerateClaimCheck]` | | [**Dead Letter Channel**](dead-letter-channel.md) | Failed-message capture and replay handoff | `[GenerateDeadLetterChannel]` | | [**Content Router**](messaging.md#generated-content-router) | Content-based message routing factories | `[GenerateContentRouter]` | diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 5960d93d..217b17b9 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -25,6 +25,9 @@ - name: Cache-Aside href: cache-aside.md +- name: Canonical Data Model + href: canonical-data-model.md + - name: Chain href: chain.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 2d02213e..1292ff71 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -52,6 +52,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Enterprise Integration | Service Activator | `ServiceActivator` | Service Activator generator | | Enterprise Integration | Message Envelope | `Message`, headers, context | Messaging generator | | Enterprise Integration | Message Translator | `MessageTranslator` | Message Translator generator | +| Enterprise Integration | Canonical Data Model | `CanonicalDataModel` | Canonical Data Model generator | | Enterprise Integration | Claim Check | `ClaimCheck` | Claim Check generator | | Enterprise Integration | Dead Letter Channel | `DeadLetterChannel` | Dead Letter Channel generator | | Enterprise Integration | Content-Based Router | `ContentRouter` | Messaging generator | diff --git a/docs/patterns/messaging/canonical-data-model.md b/docs/patterns/messaging/canonical-data-model.md new file mode 100644 index 00000000..ef697110 --- /dev/null +++ b/docs/patterns/messaging/canonical-data-model.md @@ -0,0 +1,19 @@ +# Canonical Data Model + +Canonical Data Model normalizes partner, vendor, or application-specific records into one application-owned contract. + +```csharp +var model = CanonicalDataModel + .Create("commerce-orders") + .From("partner-orders", order => new( + order.ExternalOrderId, + order.Amount, + order.CurrencyCode.ToUpperInvariant())) + .Build(); + +var result = model.Normalize(partnerOrder); +``` + +Use it when multiple integrations need to feed routers, sagas, service activators, or application services through the same stable payload shape. The runtime path reports missing adapters and mapper failures as explicit results. + +The source-generated path uses `[GenerateCanonicalDataModel]` and `[CanonicalDataModelMapper]`. Import the example through `AddCanonicalOrderDataModelDemo()` or `AddPatternKitExamples()`. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 405c8cae..c28087ee 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -311,6 +311,8 @@ href: messaging/message-envelope.md - name: Message Translator href: messaging/message-translator.md + - name: Canonical Data Model + href: messaging/canonical-data-model.md - name: Claim Check href: messaging/claim-check.md - name: Dead Letter Channel diff --git a/src/PatternKit.Core/EnterpriseIntegration/CanonicalDataModel/CanonicalDataModel.cs b/src/PatternKit.Core/EnterpriseIntegration/CanonicalDataModel/CanonicalDataModel.cs new file mode 100644 index 00000000..ca483a8d --- /dev/null +++ b/src/PatternKit.Core/EnterpriseIntegration/CanonicalDataModel/CanonicalDataModel.cs @@ -0,0 +1,106 @@ +namespace PatternKit.EnterpriseIntegration.CanonicalDataModel; + +public sealed class CanonicalDataModelResult +{ + private CanonicalDataModelResult(string modelName, string adapterName, TCanonical? value, Exception? exception, bool normalized) + => (ModelName, AdapterName, Value, Exception, Normalized) = (modelName, adapterName, value, exception, normalized); + + public string ModelName { get; } + + public string AdapterName { get; } + + public TCanonical? Value { get; } + + public Exception? Exception { get; } + + public bool Normalized { get; } + + public bool Failed => !Normalized; + + public static CanonicalDataModelResult Success(string modelName, string adapterName, TCanonical value) + => new(modelName, adapterName, value, null, true); + + public static CanonicalDataModelResult Failure(string modelName, string adapterName, Exception exception) + => new(modelName, adapterName, default, exception ?? throw new ArgumentNullException(nameof(exception)), false); +} + +public sealed class CanonicalDataModel +{ + private readonly Dictionary _adapters; + + private CanonicalDataModel(string name, IReadOnlyList adapters) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Canonical data model name is required.", nameof(name)); + if (adapters is null) + throw new ArgumentNullException(nameof(adapters)); + if (adapters.Count == 0) + throw new InvalidOperationException("Canonical data model requires at least one source adapter."); + + Name = name; + _adapters = adapters.ToDictionary(static adapter => adapter.SourceType); + } + + public string Name { get; } + + public IReadOnlyCollection SourceTypes => _adapters.Keys; + + public CanonicalDataModelResult Normalize(TSource source) + { + if (source is null) + throw new ArgumentNullException(nameof(source)); + + if (!_adapters.TryGetValue(typeof(TSource), out var adapter)) + return CanonicalDataModelResult.Failure(Name, typeof(TSource).Name, new InvalidOperationException($"No canonical data model adapter is registered for '{typeof(TSource).FullName}'.")); + + try + { + var value = adapter.Normalize(source); + if (value is null) + return CanonicalDataModelResult.Failure(Name, adapter.Name, new InvalidOperationException($"Canonical data model adapter '{adapter.Name}' returned null.")); + + return CanonicalDataModelResult.Success(Name, adapter.Name, value); + } + catch (Exception ex) + { + return CanonicalDataModelResult.Failure(Name, adapter.Name, ex); + } + } + + public static Builder Create(string name = "canonical-data-model") => new(name); + + public sealed class Builder + { + private readonly string _name; + private readonly List _adapters = []; + + internal Builder(string name) => _name = name; + + public Builder From(string adapterName, Func normalize) + { + if (string.IsNullOrWhiteSpace(adapterName)) + throw new ArgumentException("Canonical adapter name is required.", nameof(adapterName)); + if (normalize is null) + throw new ArgumentNullException(nameof(normalize)); + if (_adapters.Any(adapter => adapter.SourceType == typeof(TSource))) + throw new InvalidOperationException($"Canonical adapter for '{typeof(TSource).FullName}' is already registered."); + + _adapters.Add(new(adapterName, typeof(TSource), source => normalize((TSource)source))); + return this; + } + + public CanonicalDataModel Build() => new(_name, _adapters.ToArray()); + } + + private sealed class Adapter + { + public Adapter(string name, Type sourceType, Func normalize) + => (Name, SourceType, Normalize) = (name, sourceType, normalize); + + public string Name { get; } + + public Type SourceType { get; } + + public Func Normalize { get; } + } +} diff --git a/src/PatternKit.Examples/CanonicalDataModelDemo/OrderCanonicalDataModelDemo.cs b/src/PatternKit.Examples/CanonicalDataModelDemo/OrderCanonicalDataModelDemo.cs new file mode 100644 index 00000000..903a5a9f --- /dev/null +++ b/src/PatternKit.Examples/CanonicalDataModelDemo/OrderCanonicalDataModelDemo.cs @@ -0,0 +1,77 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.EnterpriseIntegration.CanonicalDataModel; +using PatternKit.Generators.CanonicalDataModel; + +namespace PatternKit.Examples.CanonicalDataModelDemo; + +public sealed record PartnerOrderDocument(string ExternalOrderId, decimal Amount, string CurrencyCode); + +public sealed record MarketplaceOrderDocument(string MarketplaceId, int AmountInCents, string Currency); + +public sealed record CanonicalCommerceOrder(string OrderId, decimal Total, string Currency); + +public sealed record CanonicalOrderSummary(string ModelName, string AdapterName, string OrderId, decimal Total, string Currency); + +public sealed class CanonicalOrderImportService(CanonicalDataModel model) +{ + public CanonicalOrderSummary ImportPartnerOrder(PartnerOrderDocument order) + { + var result = model.Normalize(order); + if (result.Failed) + throw new InvalidOperationException("Partner order could not be normalized.", result.Exception); + + var canonical = result.Value!; + return new(result.ModelName, result.AdapterName, canonical.OrderId, canonical.Total, canonical.Currency); + } +} + +public static class CanonicalOrderModels +{ + public static CanonicalDataModel CreateFluent() + => CanonicalDataModel.Create("commerce-orders") + .From("partner-orders", ToCanonical) + .From("marketplace-orders", static order => new( + order.MarketplaceId, + order.AmountInCents / 100m, + order.Currency.ToUpperInvariant())) + .Build(); + + public static CanonicalCommerceOrder ToCanonical(PartnerOrderDocument order) + => new(order.ExternalOrderId, order.Amount, order.CurrencyCode.ToUpperInvariant()); +} + +[GenerateCanonicalDataModel(typeof(PartnerOrderDocument), typeof(CanonicalCommerceOrder), FactoryMethodName = "Create", ModelName = "commerce-orders", AdapterName = "partner-orders")] +public static partial class GeneratedPartnerOrderCanonicalDataModel +{ + [CanonicalDataModelMapper] + private static CanonicalCommerceOrder Map(PartnerOrderDocument order) => CanonicalOrderModels.ToCanonical(order); +} + +public sealed class CanonicalOrderDemoRunner(CanonicalOrderImportService service) +{ + public CanonicalOrderSummary RunGenerated(PartnerOrderDocument order) => service.ImportPartnerOrder(order); + + public static CanonicalOrderSummary RunFluent() + { + var result = CanonicalOrderModels.CreateFluent().Normalize(new MarketplaceOrderDocument("M-200", 7500, "usd")); + var canonical = result.Value!; + return new(result.ModelName, result.AdapterName, canonical.OrderId, canonical.Total, canonical.Currency); + } + + public static CanonicalOrderSummary RunGeneratedStatic() + { + var service = new CanonicalOrderImportService(GeneratedPartnerOrderCanonicalDataModel.Create()); + return service.ImportPartnerOrder(new PartnerOrderDocument("P-100", 42.50m, "usd")); + } +} + +public static class CanonicalOrderServiceCollectionExtensions +{ + public static IServiceCollection AddCanonicalOrderDataModelDemo(this IServiceCollection services) + { + services.AddSingleton(static _ => GeneratedPartnerOrderCanonicalDataModel.Create()); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index e7d1baae..2a435bfa 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -24,6 +24,7 @@ using PatternKit.Examples.AuditLogDemo; using PatternKit.Examples.BulkheadDemo; using PatternKit.Examples.CacheAsideDemo; +using PatternKit.Examples.CanonicalDataModelDemo; using PatternKit.Examples.Chain; using PatternKit.Examples.Chain.ConfigDriven; using PatternKit.Examples.CircuitBreakerDemo; @@ -140,6 +141,7 @@ public sealed record PaymentMessagingGatewayExampleService(MessagingGateway Activator, InventoryServiceActivatorService Service); public sealed record GeneratedMessageEnvelopeExample(MessageEnvelopeExampleRunner Runner); public sealed record GeneratedMessageTranslatorExample(PartnerEventTranslatorExampleRunner Runner, PartnerOrderImportService Service); +public sealed record CanonicalOrderDataModelExample(CanonicalOrderDemoRunner Runner, CanonicalOrderImportService Service); public sealed record GeneratedClaimCheckExample(LargeDocumentClaimCheckExampleRunner Runner, LargeDocumentWorkflow Workflow); public sealed record GeneratedDeadLetterChannelExample(FulfillmentDeadLetterChannelExampleRunner Runner, FulfillmentDeadLetterWorkflow Workflow); public sealed record GeneratedRecipientListExample(RecipientListGeneratorExampleRunner Runner); @@ -226,6 +228,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddInventoryServiceActivatorExample() .AddGeneratedMessageEnvelopeExample() .AddGeneratedMessageTranslatorExample() + .AddCanonicalOrderDataModelExample() .AddGeneratedClaimCheckExample() .AddGeneratedDeadLetterChannelExample() .AddGeneratedRecipientListExample() @@ -546,6 +549,15 @@ public static IServiceCollection AddGeneratedMessageTranslatorExample(this IServ return services.RegisterExample("Generated Message Translator", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddCanonicalOrderDataModelExample(this IServiceCollection services) + { + services.AddCanonicalOrderDataModelDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService(), + sp.GetRequiredService())); + return services.RegisterExample("Order Canonical Data Model", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddGeneratedClaimCheckExample(this IServiceCollection services) { services.AddLargeDocumentClaimCheckExample(); diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 250a18dd..635527e2 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -296,6 +296,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, ["MessageTranslator"], ["partner event normalization", "source-generated translator", "DI composition"]), + Descriptor( + "Order Canonical Data Model", + "src/PatternKit.Examples/CanonicalDataModelDemo/OrderCanonicalDataModelDemo.cs", + "test/PatternKit.Examples.Tests/CanonicalDataModelDemo/OrderCanonicalDataModelDemoTests.cs", + "docs/examples/order-canonical-data-model.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["Canonical Data Model"], + ["partner order normalization", "source-generated canonical adapter", "DI composition"]), Descriptor( "Generated Claim Check", "src/PatternKit.Examples/Messaging/LargeDocumentClaimCheckExample.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 5df6e488..bf95eac5 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -467,6 +467,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/Messaging/PartnerEventTranslatorExampleTests.cs", ["fluent payload translator", "generated translator factory", "DI-importable partner event normalization example"]), + Pattern("Canonical Data Model", PatternFamily.EnterpriseIntegration, + "docs/patterns/messaging/canonical-data-model.md", + "src/PatternKit.Core/EnterpriseIntegration/CanonicalDataModel/CanonicalDataModel.cs", + "test/PatternKit.Tests/EnterpriseIntegration/CanonicalDataModel/CanonicalDataModelTests.cs", + "docs/generators/canonical-data-model.md", + "src/PatternKit.Generators/CanonicalDataModel/CanonicalDataModelGenerator.cs", + "test/PatternKit.Generators.Tests/CanonicalDataModelGeneratorTests.cs", + null, + "docs/examples/order-canonical-data-model.md", + "src/PatternKit.Examples/CanonicalDataModelDemo/OrderCanonicalDataModelDemo.cs", + "test/PatternKit.Examples.Tests/CanonicalDataModelDemo/OrderCanonicalDataModelDemoTests.cs", + ["fluent source normalization", "generated canonical adapter", "DI-importable canonical order example"]), + Pattern("Claim Check", PatternFamily.EnterpriseIntegration, "docs/patterns/messaging/claim-check.md", "src/PatternKit.Core/Messaging/Transformation/ClaimCheck.cs", diff --git a/src/PatternKit.Generators.Abstractions/EnterpriseIntegration/CanonicalDataModelAttributes.cs b/src/PatternKit.Generators.Abstractions/EnterpriseIntegration/CanonicalDataModelAttributes.cs new file mode 100644 index 00000000..aa71aaf5 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/EnterpriseIntegration/CanonicalDataModelAttributes.cs @@ -0,0 +1,20 @@ +namespace PatternKit.Generators.CanonicalDataModel; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateCanonicalDataModelAttribute(Type sourceType, Type canonicalType) : Attribute +{ + public Type SourceType { get; } = sourceType ?? throw new ArgumentNullException(nameof(sourceType)); + + public Type CanonicalType { get; } = canonicalType ?? throw new ArgumentNullException(nameof(canonicalType)); + + public string FactoryMethodName { get; set; } = "Create"; + + public string ModelName { get; set; } = "canonical-data-model"; + + public string AdapterName { get; set; } = "source-adapter"; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class CanonicalDataModelMapperAttribute : Attribute +{ +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index d335bba4..00138747 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -331,6 +331,9 @@ PKCAD002 | PatternKit.Generators.Messaging | Error | Channel Adapter must declar PKCAD003 | PatternKit.Generators.Messaging | Error | Channel Adapter must declare exactly one outbound translator. PKCAD004 | PatternKit.Generators.Messaging | Error | Channel Adapter inbound translator signature is invalid. PKCAD005 | PatternKit.Generators.Messaging | Error | Channel Adapter outbound translator signature is invalid. +PKCDM001 | PatternKit.Generators.CanonicalDataModel | Error | Canonical Data Model host must be partial. +PKCDM002 | PatternKit.Generators.CanonicalDataModel | Error | Canonical Data Model mapper is missing. +PKCDM003 | PatternKit.Generators.CanonicalDataModel | Error | Canonical Data Model mapper signature is invalid. PKGWY001 | PatternKit.Generators.Messaging | Error | Messaging Gateway host type must be partial. PKGWY002 | PatternKit.Generators.Messaging | Error | Messaging Gateway must declare exactly one handler. PKGWY003 | PatternKit.Generators.Messaging | Error | Messaging Gateway handler signature is invalid. diff --git a/src/PatternKit.Generators/CanonicalDataModel/CanonicalDataModelGenerator.cs b/src/PatternKit.Generators/CanonicalDataModel/CanonicalDataModelGenerator.cs new file mode 100644 index 00000000..bb7f1a0f --- /dev/null +++ b/src/PatternKit.Generators/CanonicalDataModel/CanonicalDataModelGenerator.cs @@ -0,0 +1,152 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.CanonicalDataModel; + +[Generator] +public sealed class CanonicalDataModelGenerator : IIncrementalGenerator +{ + private const string AttributeName = "PatternKit.Generators.CanonicalDataModel.GenerateCanonicalDataModelAttribute"; + private const string MapperAttributeName = "PatternKit.Generators.CanonicalDataModel.CanonicalDataModelMapperAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKCDM001", "Canonical Data Model host must be partial", + "Type '{0}' is marked with [GenerateCanonicalDataModel] but is not declared as partial", + "PatternKit.Generators.CanonicalDataModel", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor MissingMapper = new( + "PKCDM002", "Canonical Data Model mapper is missing", + "Canonical Data Model type '{0}' must declare exactly one [CanonicalDataModelMapper] method", + "PatternKit.Generators.CanonicalDataModel", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidMapper = new( + "PKCDM003", "Canonical Data Model mapper signature is invalid", + "Canonical Data Model mapper '{0}' must be static and return TCanonical with one TSource parameter", + "PatternKit.Generators.CanonicalDataModel", DiagnosticSeverity.Error, true); + + private static readonly SymbolDisplayFormat TypeFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + AttributeName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(static a => a.AttributeClass?.ToDisplayString() == AttributeName); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate(SourceProductionContext context, INamedTypeSymbol type, TypeDeclarationSyntax node, AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var sourceType = attribute.ConstructorArguments.Length >= 1 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + var canonicalType = attribute.ConstructorArguments.Length >= 2 ? attribute.ConstructorArguments[1].Value as INamedTypeSymbol : null; + if (sourceType is null || canonicalType is null) + return; + + var mappers = type.GetMembers().OfType() + .Where(static method => method.GetAttributes().Any(static attr => attr.AttributeClass?.ToDisplayString() == MapperAttributeName)) + .ToArray(); + if (mappers.Length != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingMapper, node.Identifier.GetLocation(), type.Name)); + return; + } + + if (!IsMapper(mappers[0], sourceType, canonicalType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMapper, mappers[0].Locations.FirstOrDefault(), mappers[0].Name)); + return; + } + + context.AddSource($"{type.Name}.CanonicalDataModel.g.cs", SourceText.From(GenerateSource( + type, + sourceType, + canonicalType, + mappers[0].Name, + GetNamedString(attribute, "FactoryMethodName") ?? "Create", + GetNamedString(attribute, "ModelName") ?? "canonical-data-model", + GetNamedString(attribute, "AdapterName") ?? "source-adapter"), Encoding.UTF8)); + } + + private static bool IsMapper(IMethodSymbol method, INamedTypeSymbol sourceType, INamedTypeSymbol canonicalType) + => method.IsStatic && + SymbolEqualityComparer.Default.Equals(method.ReturnType, canonicalType) && + method.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, sourceType); + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol sourceType, + INamedTypeSymbol canonicalType, + string mapperName, + string factoryMethodName, + string modelName, + string adapterName) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var sourceTypeName = sourceType.ToDisplayString(TypeFormat); + var canonicalTypeName = canonicalType.ToDisplayString(TypeFormat); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); + if (type.IsStatic) + sb.Append("static "); + else if (type.IsAbstract && type.TypeKind == TypeKind.Class) + sb.Append("abstract "); + else if (type.IsSealed && type.TypeKind == TypeKind.Class) + sb.Append("sealed "); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.EnterpriseIntegration.CanonicalDataModel.CanonicalDataModel<").Append(canonicalTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" return global::PatternKit.EnterpriseIntegration.CanonicalDataModel.CanonicalDataModel<").Append(canonicalTypeName).Append(">.Create(\"").Append(Escape(modelName)).AppendLine("\")"); + sb.Append(" .From<").Append(sourceTypeName).Append(">(\"").Append(Escape(adapterName)).Append("\", ").Append(mapperName).AppendLine(")"); + sb.AppendLine(" .Build();"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; +} diff --git a/test/PatternKit.Examples.Tests/CanonicalDataModelDemo/OrderCanonicalDataModelDemoTests.cs b/test/PatternKit.Examples.Tests/CanonicalDataModelDemo/OrderCanonicalDataModelDemoTests.cs new file mode 100644 index 00000000..b10979c4 --- /dev/null +++ b/test/PatternKit.Examples.Tests/CanonicalDataModelDemo/OrderCanonicalDataModelDemoTests.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.CanonicalDataModelDemo; +using PatternKit.Examples.DependencyInjection; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.CanonicalDataModelDemo; + +[Feature("Order canonical data model example")] +public sealed class OrderCanonicalDataModelDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent and generated canonical data models normalize partner orders")] + [Fact] + public Task Fluent_And_Generated_Canonical_Data_Models_Normalize_Partner_Orders() + => Given("canonical order examples", () => new + { + Fluent = CanonicalOrderDemoRunner.RunFluent(), + Generated = CanonicalOrderDemoRunner.RunGeneratedStatic() + }) + .Then("both paths produce canonical commerce orders", result => + { + ScenarioExpect.Equal("commerce-orders", result.Generated.ModelName); + ScenarioExpect.Equal("partner-orders", result.Generated.AdapterName); + ScenarioExpect.Equal("P-100", result.Generated.OrderId); + ScenarioExpect.Equal("USD", result.Generated.Currency); + ScenarioExpect.Equal("marketplace-orders", result.Fluent.AdapterName); + }) + .AssertPassed(); + + [Scenario("Canonical data model demo is importable through IServiceCollection")] + [Fact] + public Task Canonical_Data_Model_Demo_Is_Importable_Through_IServiceCollection() + => Given("an importing app service provider", () => + { + var services = new ServiceCollection(); + services.AddCanonicalOrderDataModelDemo(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("resolving and running the import service", provider => + { + using (provider) + return provider.GetRequiredService().ImportPartnerOrder(new("P-200", 18m, "usd")); + }) + .Then("the service returns a canonical order summary", summary => + { + ScenarioExpect.Equal("P-200", summary.OrderId); + ScenarioExpect.Equal(18m, summary.Total); + ScenarioExpect.Equal("USD", summary.Currency); + }) + .AssertPassed(); + + [Scenario("Aggregate examples import canonical data model demo")] + [Fact] + public Task Aggregate_Examples_Import_Canonical_Data_Model_Demo() + => Given("a PatternKit examples service provider", () => + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("resolving the aggregate canonical data model example", provider => + { + using (provider) + return provider.GetRequiredService(); + }) + .Then("the aggregate example exposes the service", example => + ScenarioExpect.NotNull(example.Service)) + .AssertPassed(); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index c83bbe68..347d4a98 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -46,6 +46,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Service Activator", "Message Envelope", "Message Translator", + "Canonical Data Model", "Claim Check", "Dead Letter Channel", "Content-Based Router", @@ -132,7 +133,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() ScenarioExpect.Equal(EnterprisePatternAdditions.OrderBy(static x => x), patterns.Select(static p => p.Name).OrderBy(static x => x))) .And("enterprise entries are grouped by integration reliability and architecture families", patterns => { - ScenarioExpect.Equal(27, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); + ScenarioExpect.Equal(28, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(9, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); ScenarioExpect.Equal(15, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index d9b7e014..973a51c9 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -2,6 +2,7 @@ using PatternKit.Generators.Bridge; using PatternKit.Generators.Bulkhead; using PatternKit.Generators.CacheAside; +using PatternKit.Generators.CanonicalDataModel; using PatternKit.Generators.Chain; using PatternKit.Generators.CircuitBreaker; using PatternKit.Generators.Cloud; @@ -75,6 +76,8 @@ private enum TestTrigger { typeof(GenerateBulkheadPolicyAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GenerateCacheAsidePolicyAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(CacheAsidePredicateAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateCanonicalDataModelAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(CanonicalDataModelMapperAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateExternalConfigurationStoreAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(ExternalConfigurationLoaderAttribute), AttributeTargets.Method, false, false }, { typeof(ExternalConfigurationValidatorAttribute), AttributeTargets.Method, false, false }, @@ -392,6 +395,27 @@ public void Bulkhead_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.Throws(() => new GenerateBulkheadPolicyAttribute(null!)); } + [Scenario("Canonical Data Model Attributes Expose Defaults And Configuration")] + [Fact] + public void CanonicalDataModel_Attributes_Expose_Defaults_And_Configuration() + { + var model = new GenerateCanonicalDataModelAttribute(typeof(string), typeof(int)) + { + FactoryMethodName = "BuildCanonicalOrders", + ModelName = "commerce-orders", + AdapterName = "partner-orders" + }; + + ScenarioExpect.Equal(typeof(string), model.SourceType); + ScenarioExpect.Equal(typeof(int), model.CanonicalType); + ScenarioExpect.Equal("BuildCanonicalOrders", model.FactoryMethodName); + ScenarioExpect.Equal("commerce-orders", model.ModelName); + ScenarioExpect.Equal("partner-orders", model.AdapterName); + ScenarioExpect.Throws(() => new GenerateCanonicalDataModelAttribute(null!, typeof(int))); + ScenarioExpect.Throws(() => new GenerateCanonicalDataModelAttribute(typeof(string), null!)); + ScenarioExpect.IsType(new CanonicalDataModelMapperAttribute()); + } + [Scenario("Priority Queue Attributes Expose Defaults And Configuration")] [Fact] public void PriorityQueue_Attributes_Expose_Defaults_And_Configuration() diff --git a/test/PatternKit.Generators.Tests/CanonicalDataModelGeneratorTests.cs b/test/PatternKit.Generators.Tests/CanonicalDataModelGeneratorTests.cs new file mode 100644 index 00000000..b469b45f --- /dev/null +++ b/test/PatternKit.Generators.Tests/CanonicalDataModelGeneratorTests.cs @@ -0,0 +1,85 @@ +using Microsoft.CodeAnalysis; +using PatternKit.EnterpriseIntegration.CanonicalDataModel; +using PatternKit.Generators.CanonicalDataModel; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Canonical Data Model generator")] +public sealed partial class CanonicalDataModelGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates canonical data model factory")] + [Fact] + public Task Generates_Canonical_Data_Model_Factory() + => Given("a canonical data model declaration", () => Compile(""" + using PatternKit.Generators.CanonicalDataModel; + namespace Demo; + public sealed record PartnerOrder(string Id, decimal Amount); + public sealed record CanonicalOrder(string OrderId, decimal Total); + [GenerateCanonicalDataModel(typeof(PartnerOrder), typeof(CanonicalOrder), FactoryMethodName = "Build", ModelName = "commerce-orders", AdapterName = "partner")] + public static partial class PartnerOrderCanonicalModel + { + [CanonicalDataModelMapper] + private static CanonicalOrder Map(PartnerOrder order) => new(order.Id, order.Amount); + } + """)) + .Then("the generated source creates the configured model", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("Build()", source); + ScenarioExpect.Contains("CanonicalDataModel.Create(\"commerce-orders\")", source); + ScenarioExpect.Contains(".From(\"partner\", Map)", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid canonical data model declarations")] + [Fact] + public Task Reports_Diagnostics_For_Invalid_Canonical_Data_Model_Declarations() + => Given("invalid canonical data model declarations", () => new[] + { + Compile(""" + using PatternKit.Generators.CanonicalDataModel; + [GenerateCanonicalDataModel(typeof(string), typeof(int))] + public static class CanonicalHost; + """), + Compile(""" + using PatternKit.Generators.CanonicalDataModel; + [GenerateCanonicalDataModel(typeof(string), typeof(int))] + public static partial class CanonicalHost; + """), + Compile(""" + using PatternKit.Generators.CanonicalDataModel; + [GenerateCanonicalDataModel(typeof(string), typeof(int))] + public static partial class CanonicalHost + { + [CanonicalDataModelMapper] + private static string Map(string value) => value; + } + """) + }) + .Then("diagnostics identify the invalid declarations", results => + { + ScenarioExpect.Contains(results[0].Diagnostics, diagnostic => diagnostic.Id == "PKCDM001"); + ScenarioExpect.Contains(results[1].Diagnostics, diagnostic => diagnostic.Id == "PKCDM002"); + ScenarioExpect.Contains(results[2].Diagnostics, diagnostic => diagnostic.Id == "PKCDM003"); + }) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "CanonicalDataModelGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(CanonicalDataModel<>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new CanonicalDataModelGenerator(), out var run, out var updated); + var result = run.Results.Single(); + var emit = updated.Emit(Stream.Null); + return new(result.Diagnostics.ToArray(), result.GeneratedSources.Select(static source => source.SourceText.ToString()).ToArray(), emit.Success, emit.Diagnostics.Select(static diagnostic => diagnostic.ToString()).ToArray()); + } + + private sealed record GeneratorResult(IReadOnlyList Diagnostics, IReadOnlyList GeneratedSources, bool EmitSuccess, IReadOnlyList EmitDiagnostics); +} diff --git a/test/PatternKit.Tests/EnterpriseIntegration/CanonicalDataModel/CanonicalDataModelTests.cs b/test/PatternKit.Tests/EnterpriseIntegration/CanonicalDataModel/CanonicalDataModelTests.cs new file mode 100644 index 00000000..501f6da4 --- /dev/null +++ b/test/PatternKit.Tests/EnterpriseIntegration/CanonicalDataModel/CanonicalDataModelTests.cs @@ -0,0 +1,81 @@ +using PatternKit.EnterpriseIntegration.CanonicalDataModel; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.EnterpriseIntegration.CanonicalDataModel; + +[Feature("Canonical Data Model")] +public sealed class CanonicalDataModelTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Canonical data model normalizes different source contracts")] + [Fact] + public Task Canonical_Data_Model_Normalizes_Different_Source_Contracts() + => Given("a canonical order model", CreateModel) + .When("partner and marketplace orders are normalized", model => new[] + { + model.Normalize(new PartnerOrder("P-100", 42.50m, "USD")).Value!, + model.Normalize(new MarketplaceOrder("M-200", 75_00, "usd")).Value! + }) + .Then("both contracts become canonical orders", orders => + { + ScenarioExpect.Equal("P-100", orders[0].OrderId); + ScenarioExpect.Equal(42.50m, orders[0].Total); + ScenarioExpect.Equal("M-200", orders[1].OrderId); + ScenarioExpect.Equal(75m, orders[1].Total); + }) + .AssertPassed(); + + [Scenario("Canonical data model reports missing adapters and mapper failures")] + [Fact] + public Task Canonical_Data_Model_Reports_Missing_Adapters_And_Mapper_Failures() + => Given("a canonical model with a failing adapter", () => CanonicalDataModel.Create("orders") + .From("partner", static _ => throw new InvalidOperationException("bad partner payload")) + .Build()) + .When("unsupported and failing sources are normalized", model => new + { + Unsupported = model.Normalize(new MarketplaceOrder("M-200", 7500, "USD")), + Failed = model.Normalize(new PartnerOrder("P-100", 42m, "USD")) + }) + .Then("both failures are explicit results", result => + { + ScenarioExpect.True(result.Unsupported.Failed); + ScenarioExpect.True(result.Failed.Failed); + ScenarioExpect.Contains("No canonical data model adapter", result.Unsupported.Exception!.Message); + ScenarioExpect.Equal("bad partner payload", result.Failed.Exception!.Message); + }) + .AssertPassed(); + + [Scenario("Canonical data model validates configuration")] + [Fact] + public Task Canonical_Data_Model_Validates_Configuration() + => Given("invalid canonical model inputs", () => true) + .Then("invalid model names are rejected", _ => + ScenarioExpect.Throws(() => CanonicalDataModel.Create("").From("partner", ToCanonical).Build())) + .And("missing adapters are rejected", _ => + ScenarioExpect.Throws(() => CanonicalDataModel.Create().Build())) + .And("invalid adapter names are rejected", _ => + ScenarioExpect.Throws(() => CanonicalDataModel.Create().From("", ToCanonical))) + .And("null mappers are rejected", _ => + ScenarioExpect.Throws(() => CanonicalDataModel.Create().From("partner", null!))) + .And("duplicate source adapters are rejected", _ => + ScenarioExpect.Throws(() => CanonicalDataModel.Create().From("partner", ToCanonical).From("partner2", ToCanonical))) + .And("null source values are rejected", _ => + ScenarioExpect.Throws(() => CreateModel().Normalize(null!))) + .AssertPassed(); + + private static CanonicalDataModel CreateModel() + => CanonicalDataModel.Create("commerce-orders") + .From("partner-order", ToCanonical) + .From("marketplace-order", static order => new(order.Id, order.AmountInCents / 100m, order.Currency.ToUpperInvariant())) + .Build(); + + private static CanonicalOrder ToCanonical(PartnerOrder order) + => new(order.ExternalId, order.Amount, order.Currency.ToUpperInvariant()); + + private sealed record PartnerOrder(string ExternalId, decimal Amount, string Currency); + + private sealed record MarketplaceOrder(string Id, int AmountInCents, string Currency); + + private sealed record CanonicalOrder(string OrderId, decimal Total, string Currency); +}