From 9747347041acfc4999690742049c108a73653fee Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Tue, 30 Jun 2026 10:13:06 +0200 Subject: [PATCH 1/8] Add type-safe Custom API request/response wrappers Mirror the type-safe image feature for Custom APIs. The typed overload RegisterAPI(name, handlerMethodName) opts in: the source generator emits {ApiName}Request/{ApiName}Response classes (named after the API, in the plugin's namespace) from the AddRequestParameter/AddResponseProperty calls, plus an internal ActionWrapper that marshals InputParameters into the request and the returned response into OutputParameters. The handler signature adapts when no request parameters (no argument) or no response properties (void return) are declared. - Public API/runtime: new typed RegisterAPI overload; CustomApiRegistration carries the handler method; wrapper discovery unified behind PluginStepRegistration.WrapperTypeName (computed at registration time for both plugin steps and Custom APIs). - Source generator: CustomApiGenerator + parser, metadata, type mapper, code generator, and shared helpers. - Diagnostics + fixers: XPC4004 (handler not found), XPC4005/XPC4006 (signature mismatch warning/error), and XPC3001 extended to the Custom API handler arg. - Generated code is backwards compatible with consumers that have nullable reference types disabled (incl. .NET Framework / C# 7.3): reference-type `?` annotations and the `#nullable enable` directive are emitted only when NRT is enabled; nullable value types are always emitted. This also fixes the image generator emitting `string?` (and CS8669) on NRT-off projects. - Tests: generator output, analyzer/fixer, and end-to-end runtime coverage. - Docs: CHANGELOG, CLAUDE.md, README, and rules/XPC400{4,5,6}.md. Co-Authored-By: Claude via Conducktor --- CLAUDE.md | 50 ++++ README.md | 45 ++++ .../CustomApiHandlerDiagnosticsTests.cs | 168 +++++++++++++ .../CustomApiClassGenerationTests.cs | 141 +++++++++++ .../Helpers/CompilationHelper.cs | 7 +- .../Helpers/GeneratorTestHelper.cs | 13 +- .../Helpers/TestFixtures.cs | 58 +++++ .../AnalyzerReleases.Unshipped.md | 3 + .../CustomApiHandlerMethodNotFoundAnalyzer.cs | 89 +++++++ ...stomApiHandlerSignatureMismatchAnalyzer.cs | 152 ++++++++++++ .../Analyzers/PreferNameofAnalyzer.cs | 73 ++++-- ...teCustomApiHandlerMethodCodeFixProvider.cs | 177 ++++++++++++++ ...ustomApiHandlerSignatureCodeFixProvider.cs | 170 +++++++++++++ .../CodeGeneration/CustomApiClassGenerator.cs | 202 +++++++++++++++ .../CodeGeneration/Indent.cs | 3 + .../CodeGeneration/WrapperClassGenerator.cs | 8 +- XrmPluginCore.SourceGenerator/Constants.cs | 16 ++ .../DiagnosticDescriptors.cs | 27 ++ .../Generators/CustomApiGenerator.cs | 98 ++++++++ .../Generators/PluginImageGenerator.cs | 5 +- .../Helpers/CustomApiGenerationContext.cs | 60 +++++ .../Helpers/CustomApiHandlerSyntaxHelper.cs | 51 ++++ .../Helpers/CustomApiParameterTypeMapper.cs | 52 ++++ .../Helpers/IdentifierHelper.cs | 74 ++++++ .../Helpers/NullableHelper.cs | 43 ++++ .../Helpers/RegisterApiHelper.cs | 146 +++++++++++ .../Models/CustomApiMetadata.cs | 136 +++++++++++ .../Models/PluginStepMetadata.cs | 7 +- .../Parsers/CustomApiRegistrationParser.cs | 230 ++++++++++++++++++ .../Parsers/RegistrationParser.cs | 31 ++- .../rules/XPC4004.md | 53 ++++ .../rules/XPC4005.md | 66 +++++ .../rules/XPC4006.md | 49 ++++ .../TypeSafe/TypeSafeCustomApi.cs | 28 +++ .../TypeSafe/TypeSafeCustomApiService.cs | 17 ++ XrmPluginCore.Tests/TypeSafeCustomApiTests.cs | 93 +++++++ XrmPluginCore/CHANGELOG.md | 7 + .../CustomApis/CustomApiConfigBuilder.cs | 5 + .../CustomApis/CustomApiRegistration.cs | 15 ++ XrmPluginCore/Helpers/IdentifierSanitizer.cs | 48 ++++ XrmPluginCore/Plugin.cs | 77 ++++-- .../Plugins/PluginStepRegistration.cs | 28 +-- 42 files changed, 2741 insertions(+), 80 deletions(-) create mode 100644 XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/CustomApiHandlerDiagnosticsTests.cs create mode 100644 XrmPluginCore.SourceGenerator.Tests/GenerationTests/CustomApiClassGenerationTests.cs create mode 100644 XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerMethodNotFoundAnalyzer.cs create mode 100644 XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerSignatureMismatchAnalyzer.cs create mode 100644 XrmPluginCore.SourceGenerator/CodeFixes/CreateCustomApiHandlerMethodCodeFixProvider.cs create mode 100644 XrmPluginCore.SourceGenerator/CodeFixes/FixCustomApiHandlerSignatureCodeFixProvider.cs create mode 100644 XrmPluginCore.SourceGenerator/CodeGeneration/CustomApiClassGenerator.cs create mode 100644 XrmPluginCore.SourceGenerator/Generators/CustomApiGenerator.cs create mode 100644 XrmPluginCore.SourceGenerator/Helpers/CustomApiGenerationContext.cs create mode 100644 XrmPluginCore.SourceGenerator/Helpers/CustomApiHandlerSyntaxHelper.cs create mode 100644 XrmPluginCore.SourceGenerator/Helpers/CustomApiParameterTypeMapper.cs create mode 100644 XrmPluginCore.SourceGenerator/Helpers/IdentifierHelper.cs create mode 100644 XrmPluginCore.SourceGenerator/Helpers/NullableHelper.cs create mode 100644 XrmPluginCore.SourceGenerator/Helpers/RegisterApiHelper.cs create mode 100644 XrmPluginCore.SourceGenerator/Models/CustomApiMetadata.cs create mode 100644 XrmPluginCore.SourceGenerator/Parsers/CustomApiRegistrationParser.cs create mode 100644 XrmPluginCore.SourceGenerator/rules/XPC4004.md create mode 100644 XrmPluginCore.SourceGenerator/rules/XPC4005.md create mode 100644 XrmPluginCore.SourceGenerator/rules/XPC4006.md create mode 100644 XrmPluginCore.Tests/TestCustomApis/TypeSafe/TypeSafeCustomApi.cs create mode 100644 XrmPluginCore.Tests/TestCustomApis/TypeSafe/TypeSafeCustomApiService.cs create mode 100644 XrmPluginCore.Tests/TypeSafeCustomApiTests.cs create mode 100644 XrmPluginCore/Helpers/IdentifierSanitizer.cs diff --git a/CLAUDE.md b/CLAUDE.md index 246b94d..7f22ab8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -289,6 +289,56 @@ All three methods are valid and supported. `WithPreImage` and `WithPostImage` ar - **Namespace isolation**: Each step gets its own namespace, preventing naming conflicts - **Shared interfaces**: `IPluginImage`/`IPluginPreImage`/`IPluginPostImage` (and generic variants) let handler methods share logic across the per-registration concrete image types +### Type-Safe Custom API Request/Response + +The source generator provides the same compile-time safety for Custom APIs that it provides for plugin images. The typed overload `RegisterAPI(string name, string handlerMethodName)` opts in: from the `AddRequestParameter`/`AddResponseProperty` declarations, the generator emits a `Request` and `Response` class **named after the API and placed in the plugin's own namespace**, plus an internal `ActionWrapper` discovered at runtime by naming convention. + +#### API Design + +```csharp +public class SomeCustomApi : Plugin +{ + public SomeCustomApi() + { + RegisterAPI(nameof(SomeCustomApi), nameof(CallbackService.SomeCustomApiMethod)) + .AddRequestParameter("EntityLogicalName", CustomApiParameterType.String) + .AddRequestParameter("EntityId", CustomApiParameterType.Guid) + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer) + .AddResponseProperty("ErrorMessage", CustomApiParameterType.String); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + => services.AddScoped(); +} + +public class CallbackService +{ + // Signature is enforced by the source generator (XPC4004/XPC4005/XPC4006) + public SomeCustomApiResponse SomeCustomApiMethod(SomeCustomApiRequest request) + { + var id = request.EntityId; // strongly-typed, from InputParameters["EntityId"] + return new SomeCustomApiResponse(200, string.Empty); + } +} +``` + +#### How It Works + +1. **Property names**: each request/response property is named after the *constant value* of the unique-name argument (so `AddRequestParameter("EntityId", ...)` and `AddRequestParameter(CallbackService.EntityId, ...)` both yield an `EntityId` property). The InputParameters/OutputParameters dictionary keys use that same unique name. +2. **Types**: `CustomApiParameterType` is mapped to a CLR type (e.g. `String` → `string`, `Guid` → `System.Guid`, `Integer` → `int`, `Money` → `Microsoft.Xrm.Sdk.Money`). Optional value-type request parameters become nullable (`int?`). +3. **Response shape**: the generated `Response` has settable properties **and** an all-args constructor, so it can be built with `new XResponse(200, "")` or an object initializer. +4. **Signature adaptation**: when no request parameters are declared the handler takes no argument; when no response properties are declared it returns `void`. +5. **Runtime execution**: the generated `ActionWrapper` reads `IPluginExecutionContext.InputParameters` into the request, invokes the handler, and writes the returned response's properties into `OutputParameters`. + +#### Diagnostics + +| Rule | Severity | Meaning | +| --- | --- | --- | +| XPC4004 | Error | Custom API handler method not found on the service type (code fix creates it) | +| XPC4005 | Warning | Handler signature doesn't match the declared parameters, generated types don't exist yet | +| XPC4006 | Error | Handler signature doesn't match, generated types exist (code fix corrects it) | +| XPC3001 | Warning | Prefer `nameof(TService.Method)` over a string literal for the handler argument | + ### Dependency Injection XrmPluginCore supports three patterns for registering custom services: diff --git a/README.md b/README.md index b16b516..a8a9bc1 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ XrmPluginCore provides base functionality for developing plugins and custom APIs - **Context Wrappers**: Simplify access to plugin execution context - **Registration Utilities**: Easily register plugins and custom APIs - **Type-Safe Images**: Compile-time type safety for PreImages and PostImages via source generators +- **Type-Safe Custom APIs**: Generated request/response classes for Custom APIs via source generators - **Compatibility**: Supports .NET Framework 4.6.2 and .NET 8 ## Usage @@ -204,6 +205,50 @@ The source generator includes analyzers that help catch common issues at compile | [XPC4001](XrmPluginCore.SourceGenerator/rules/XPC4001.md) | Error | Handler method not found | | [XPC4002](XrmPluginCore.SourceGenerator/rules/XPC4002.md) | Warning | Handler signature does not match registered images | | [XPC4003](XrmPluginCore.SourceGenerator/rules/XPC4003.md) | Error | Handler signature does not match registered images | +| [XPC4004](XrmPluginCore.SourceGenerator/rules/XPC4004.md) | Error | Custom API handler method not found | +| [XPC4005](XrmPluginCore.SourceGenerator/rules/XPC4005.md) | Warning | Custom API handler signature does not match registered parameters | +| [XPC4006](XrmPluginCore.SourceGenerator/rules/XPC4006.md) | Error | Custom API handler signature does not match registered parameters | + +### Type-Safe Custom APIs + +The source generator also creates type-safe request/response classes for Custom APIs. Use the typed overload `RegisterAPI(name, handlerMethodName)` and declare the parameters with `AddRequestParameter`/`AddResponseProperty`. The generator emits a `{ApiName}Request` and `{ApiName}Response` class (named after the API, in your plugin's namespace) and wires up an `ActionWrapper` that reads `InputParameters` into the request and writes the returned response into `OutputParameters`. + +#### Quick Start + +```csharp +public class SomeCustomApi : Plugin +{ + public SomeCustomApi() + { + RegisterAPI(nameof(SomeCustomApi), nameof(CallbackService.SomeCustomApiMethod)) + .AddRequestParameter("EntityLogicalName", CustomApiParameterType.String) + .AddRequestParameter("EntityId", CustomApiParameterType.Guid) + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer) + .AddResponseProperty("ErrorMessage", CustomApiParameterType.String); + // Source generator validates that SomeCustomApiMethod accepts the request and returns the response + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + => services.AddScoped(); +} + +public class CallbackService +{ + public SomeCustomApiResponse SomeCustomApiMethod(SomeCustomApiRequest request) + { + var id = request.EntityId; // strongly-typed, from InputParameters["EntityId"] + return new SomeCustomApiResponse(200, string.Empty); + } +} +``` + +The generated `Request` exposes a settable property per request parameter; the `Response` exposes a settable property per response property plus an all-args constructor, so it can be built with `new SomeCustomApiResponse(200, "")` or an object initializer. + +- **Property names** come from the unique-name argument (e.g. `"EntityId"` → `EntityId`), which is also the `InputParameters`/`OutputParameters` key. +- **Parameter types** map to CLR types (`String` → `string`, `Guid` → `System.Guid`, `Integer` → `int`, `Money` → `Money`, …). Optional value-type request parameters become nullable (`int?`). +- **Signature adapts**: with no request parameters the handler takes no argument; with no response properties it returns `void`. + +The handler signature is enforced by analyzers (XPC4004/XPC4005/XPC4006), each with a code fix. ### Using the LocalPluginContext wrapper (Legacy) diff --git a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/CustomApiHandlerDiagnosticsTests.cs b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/CustomApiHandlerDiagnosticsTests.cs new file mode 100644 index 0000000..ac0e5d7 --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/CustomApiHandlerDiagnosticsTests.cs @@ -0,0 +1,168 @@ +using FluentAssertions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; +using XrmPluginCore.SourceGenerator.Analyzers; +using XrmPluginCore.SourceGenerator.CodeFixes; +using XrmPluginCore.SourceGenerator.Tests.Helpers; +using Xunit; + +namespace XrmPluginCore.SourceGenerator.Tests.DiagnosticTests; + +/// +/// Tests for the type-safe Custom API analyzers (XPC4004/XPC4005/XPC4006, XPC3001) and their code fixers. +/// +public class CustomApiHandlerDiagnosticsTests : CodeFixTestBase +{ + private const string RegistrationWithParams = """ + RegisterAPI(nameof(SomeApi), nameof(CallbackService.Handle)) + .AddRequestParameter("EntityId", CustomApiParameterType.Guid) + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer); + """; + + [Fact] + public async Task Should_Report_XPC4004_When_Handler_Method_Missing() + { + var source = WrapPlugin(RegistrationWithParams, serviceBody: "// no methods"); + + var diagnostics = await GetDiagnosticsAsync(source, new CustomApiHandlerMethodNotFoundAnalyzer()); + + diagnostics.Should().ContainSingle(d => d.Id == "XPC4004"); + diagnostics.Single(d => d.Id == "XPC4004").Severity.Should().Be(DiagnosticSeverity.Error); + } + + [Fact] + public async Task Should_Report_XPC4005_When_Signature_Wrong_And_Types_Missing() + { + // Handler exists but has the wrong signature; the generated request/response types do not exist. + var source = WrapPlugin(RegistrationWithParams, serviceBody: "public void Handle() { }"); + + var diagnostics = await GetDiagnosticsAsync(source, new CustomApiHandlerSignatureMismatchAnalyzer()); + + var diagnostic = diagnostics.Should().ContainSingle(d => d.Id == "XPC4005").Subject; + diagnostic.Severity.Should().Be(DiagnosticSeverity.Warning); + diagnostic.GetMessage().Should().Contain("SomeApiResponse Handle(SomeApiRequest request)"); + } + + [Fact] + public async Task Should_Report_XPC4006_When_Signature_Wrong_And_Types_Exist() + { + // The generated types exist in the compilation, so the mismatch escalates to an error. + var source = WrapPlugin(RegistrationWithParams, serviceBody: "public void Handle() { }") + + GeneratedTypes; + + var diagnostics = await GetDiagnosticsAsync(source, new CustomApiHandlerSignatureMismatchAnalyzer()); + + diagnostics.Should().ContainSingle(d => d.Id == "XPC4006") + .Which.Severity.Should().Be(DiagnosticSeverity.Error); + } + + [Fact] + public async Task Should_Not_Report_When_Signature_Matches() + { + var source = WrapPlugin(RegistrationWithParams, serviceBody: "public SomeApiResponse Handle(SomeApiRequest request) => new SomeApiResponse(0);") + + GeneratedTypes; + + var diagnostics = await GetDiagnosticsAsync(source, new CustomApiHandlerSignatureMismatchAnalyzer()); + + diagnostics.Should().NotContain(d => d.Id == "XPC4005" || d.Id == "XPC4006"); + } + + [Fact] + public async Task Should_Report_XPC3001_For_String_Literal_Handler() + { + const string registration = """ + RegisterAPI(nameof(SomeApi), "Handle") + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer); + """; + var source = WrapPlugin(registration, serviceBody: "public SomeApiResponse Handle() => new SomeApiResponse(0);") + + GeneratedTypes; + + var diagnostics = await GetDiagnosticsAsync(source, new PreferNameofAnalyzer()); + + var diagnostic = diagnostics.Should().ContainSingle(d => d.Id == "XPC3001").Subject; + diagnostic.Properties["ServiceType"].Should().Be("CallbackService"); + diagnostic.Properties["MethodName"].Should().Be("Handle"); + } + + [Fact] + public async Task Should_Fix_Missing_Handler_Method() + { + var source = WrapPlugin(RegistrationWithParams, serviceBody: "// no methods"); + + var fixedSource = await ApplyCodeFixAsync( + source, + new CustomApiHandlerMethodNotFoundAnalyzer(), + new CreateCustomApiHandlerMethodCodeFixProvider(), + DiagnosticDescriptors.CustomApiHandlerMethodNotFound.Id); + + fixedSource.Should().Contain("TestNamespace.SomeApiResponse Handle(TestNamespace.SomeApiRequest request)"); + } + + [Fact] + public async Task Should_Fix_Wrong_Handler_Signature() + { + var source = WrapPlugin(RegistrationWithParams, serviceBody: "public void Handle() { }") + + GeneratedTypes; + + var fixedSource = await ApplyCodeFixAsync( + source, + new CustomApiHandlerSignatureMismatchAnalyzer(), + new FixCustomApiHandlerSignatureCodeFixProvider(), + DiagnosticDescriptors.CustomApiHandlerSignatureMismatch.Id, + DiagnosticDescriptors.CustomApiHandlerSignatureMismatchError.Id); + + fixedSource.Should().Contain("Handle(TestNamespace.SomeApiRequest request)"); + fixedSource.Should().Contain("TestNamespace.SomeApiResponse Handle"); + } + + private const string GeneratedTypes = """ + + + namespace TestNamespace + { + public sealed class SomeApiRequest { public System.Guid EntityId { get; set; } } + public sealed class SomeApiResponse + { + public int StatusCode { get; set; } + public SomeApiResponse(int statusCode) { StatusCode = statusCode; } + } + } + """; + + private static string WrapPlugin(string registration, string serviceBody) => + $$""" + using System; + using XrmPluginCore; + using XrmPluginCore.Enums; + using Microsoft.Extensions.DependencyInjection; + + namespace TestNamespace + { + public class SomeApi : Plugin + { + public SomeApi() + { + {{registration}} + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public class CallbackService + { + {{serviceBody}} + } + } + """; + + private static async Task> GetDiagnosticsAsync(string source, DiagnosticAnalyzer analyzer) + { + var compilation = CompilationHelper.CreateCompilation(source); + var compilationWithAnalyzers = compilation.WithAnalyzers([analyzer]); + return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); + } +} diff --git a/XrmPluginCore.SourceGenerator.Tests/GenerationTests/CustomApiClassGenerationTests.cs b/XrmPluginCore.SourceGenerator.Tests/GenerationTests/CustomApiClassGenerationTests.cs new file mode 100644 index 0000000..5fd5f9f --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/GenerationTests/CustomApiClassGenerationTests.cs @@ -0,0 +1,141 @@ +using FluentAssertions; +using XrmPluginCore.SourceGenerator.Tests.Helpers; +using Xunit; + +namespace XrmPluginCore.SourceGenerator.Tests.GenerationTests; + +/// +/// Tests for the type-safe Custom API Request/Response/ActionWrapper code generation. +/// +public class CustomApiClassGenerationTests +{ + [Fact] + public void Should_Generate_Request_Class_With_Properties() + { + var result = GeneratorTestHelper.RunCustomApiGenerator( + CompilationHelper.CreateCompilation(TestFixtures.GetCustomApiPlugin())); + + result.GeneratedTrees.Should().NotBeEmpty(); + var generated = result.GeneratedTrees[0].GetText().ToString(); + + generated.Should().Contain("public sealed class SomeApiRequest"); + // NRT is enabled for this test compilation, so reference types are annotated nullable + generated.Should().Contain("public string? EntityLogicalName { get; set; }"); + generated.Should().Contain("public System.Guid EntityId { get; set; }"); + // Optional value-type parameter becomes nullable + generated.Should().Contain("public int? Count { get; set; }"); + // The generated file opts into the nullable context so the annotations are valid + generated.Should().Contain("#nullable enable"); + } + + [Fact] + public void Should_Generate_Response_Class_With_Constructor() + { + var result = GeneratorTestHelper.RunCustomApiGenerator( + CompilationHelper.CreateCompilation(TestFixtures.GetCustomApiPlugin())); + + var generated = result.GeneratedTrees[0].GetText().ToString(); + + generated.Should().Contain("public sealed class SomeApiResponse"); + generated.Should().Contain("public int StatusCode { get; set; }"); + generated.Should().Contain("public string? ErrorMessage { get; set; }"); + generated.Should().Contain("public SomeApiResponse(int statusCode, string? errorMessage)"); + generated.Should().Contain("StatusCode = statusCode;"); + generated.Should().Contain("ErrorMessage = errorMessage;"); + } + + [Fact] + public void Should_Generate_ActionWrapper_Marshalling_Inputs_And_Outputs() + { + var result = GeneratorTestHelper.RunCustomApiGenerator( + CompilationHelper.CreateCompilation(TestFixtures.GetCustomApiPlugin())); + + var generated = result.GeneratedTrees[0].GetText().ToString(); + + generated.Should().Contain("internal sealed class SomeApiActionWrapper : IActionWrapper"); + generated.Should().Contain("var service = serviceProvider.GetRequiredService();"); + generated.Should().Contain("var request = new SomeApiRequest"); + generated.Should().Contain("EntityLogicalName = context.InputParameters.Contains(\"EntityLogicalName\") ? (string?)context.InputParameters[\"EntityLogicalName\"] : default,"); + generated.Should().Contain("var response = service.Handle(request);"); + generated.Should().Contain("context.OutputParameters[\"StatusCode\"] = response.StatusCode;"); + generated.Should().Contain("context.OutputParameters[\"ErrorMessage\"] = response.ErrorMessage;"); + } + + [Fact] + public void Should_Omit_Request_When_No_Request_Parameters() + { + var result = GeneratorTestHelper.RunCustomApiGenerator( + CompilationHelper.CreateCompilation(TestFixtures.GetCustomApiPlugin(withRequest: false))); + + var generated = result.GeneratedTrees[0].GetText().ToString(); + + generated.Should().NotContain("class SomeApiRequest"); + generated.Should().Contain("public sealed class SomeApiResponse"); + // Handler invoked with no argument + generated.Should().Contain("var response = service.Handle();"); + generated.Should().NotContain("var request = new"); + } + + [Fact] + public void Should_Return_Void_When_No_Response_Properties() + { + var result = GeneratorTestHelper.RunCustomApiGenerator( + CompilationHelper.CreateCompilation(TestFixtures.GetCustomApiPlugin(withResponse: false))); + + var generated = result.GeneratedTrees[0].GetText().ToString(); + + generated.Should().NotContain("class SomeApiResponse"); + generated.Should().Contain("public sealed class SomeApiRequest"); + // Handler invoked as a statement (void return), no OutputParameters writes + generated.Should().Contain("service.Handle(request);"); + generated.Should().NotContain("var response ="); + generated.Should().NotContain("context.OutputParameters["); + } + + [Fact] + public void Should_Not_Annotate_Reference_Types_When_Nullable_Disabled() + { + // Backwards compatibility: with NRT disabled (e.g. .NET Framework / C# 7.3 defaults), the + // generated code must not contain reference-type '?' annotations nor a #nullable directive. + var result = GeneratorTestHelper.RunCustomApiGenerator( + CompilationHelper.CreateCompilation( + TestFixtures.GetCustomApiPlugin(), + nullableContextOptions: Microsoft.CodeAnalysis.NullableContextOptions.Disable)); + + var generated = result.GeneratedTrees[0].GetText().ToString(); + + generated.Should().NotContain("#nullable"); + generated.Should().Contain("public string EntityLogicalName { get; set; }"); + generated.Should().Contain("public string ErrorMessage { get; set; }"); + generated.Should().NotContain("string?"); + // Nullable value types are still emitted (System.Nullable is valid everywhere) + generated.Should().Contain("public int? Count { get; set; }"); + } + + [Fact] + public void Should_Not_Generate_For_Action_Based_RegisterAPI() + { + // The action-based RegisterAPI overload (not the typed handler-name overload) must not trigger generation. + const string source = """ + using XrmPluginCore; + using XrmPluginCore.Enums; + + namespace TestNamespace + { + public class SomeApi : Plugin + { + public SomeApi() + { + RegisterCustomAPI("some_api", ctx => { }) + .AddRequestParameter("EntityId", CustomApiParameterType.Guid); + } + } + } + """; + + var result = GeneratorTestHelper.RunCustomApiGenerator( + CompilationHelper.CreateCompilation(source)); + + result.GeneratedTrees.Should().BeEmpty(); + } +} diff --git a/XrmPluginCore.SourceGenerator.Tests/Helpers/CompilationHelper.cs b/XrmPluginCore.SourceGenerator.Tests/Helpers/CompilationHelper.cs index a06ae90..5068ddc 100644 --- a/XrmPluginCore.SourceGenerator.Tests/Helpers/CompilationHelper.cs +++ b/XrmPluginCore.SourceGenerator.Tests/Helpers/CompilationHelper.cs @@ -16,7 +16,10 @@ public static class CompilationHelper /// The C# source code to compile /// Optional assembly name (defaults to random GUID) /// A configured CSharpCompilation - public static CSharpCompilation CreateCompilation(string source, string? assemblyName = null) + public static CSharpCompilation CreateCompilation( + string source, + string? assemblyName = null, + NullableContextOptions nullableContextOptions = NullableContextOptions.Enable) { var syntaxTree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.CSharp11)); @@ -28,7 +31,7 @@ public static CSharpCompilation CreateCompilation(string source, string? assembl references, new CSharpCompilationOptions( OutputKind.DynamicallyLinkedLibrary, - nullableContextOptions: NullableContextOptions.Enable)); + nullableContextOptions: nullableContextOptions)); } /// diff --git a/XrmPluginCore.SourceGenerator.Tests/Helpers/GeneratorTestHelper.cs b/XrmPluginCore.SourceGenerator.Tests/Helpers/GeneratorTestHelper.cs index 51b2e12..b6aee09 100644 --- a/XrmPluginCore.SourceGenerator.Tests/Helpers/GeneratorTestHelper.cs +++ b/XrmPluginCore.SourceGenerator.Tests/Helpers/GeneratorTestHelper.cs @@ -15,8 +15,19 @@ public static class GeneratorTestHelper /// Runs the PluginImageGenerator on the provided compilation and returns the updated compilation. /// public static GeneratorRunResult RunGenerator(CSharpCompilation compilation) + => RunGenerator(compilation, new PluginImageGenerator()); + + /// + /// Runs the CustomApiGenerator on the provided compilation and returns the updated compilation. + /// + public static GeneratorRunResult RunCustomApiGenerator(CSharpCompilation compilation) + => RunGenerator(compilation, new CustomApiGenerator()); + + /// + /// Runs the specified incremental generator on the provided compilation and returns the result. + /// + public static GeneratorRunResult RunGenerator(CSharpCompilation compilation, IIncrementalGenerator generator) { - var generator = new PluginImageGenerator(); // Pass the compilation's parse options to the driver so generated syntax trees use the same language version var driver = CSharpGeneratorDriver.Create( generators: [generator.AsSourceGenerator()], diff --git a/XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs b/XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs index af75d06..96ddf23 100644 --- a/XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs +++ b/XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs @@ -321,6 +321,64 @@ public void HandleAccountUpdate() { } } """; + /// + /// A type-safe Custom API plugin and service. The Request/Response types referenced by the handler + /// are emitted by the CustomApiGenerator; this fixture only needs to declare the registration and a + /// matching handler. Use / to exercise + /// the signature-adaptation paths. + /// + public static string GetCustomApiPlugin(bool withRequest = true, bool withResponse = true) + { + var requestChain = withRequest + ? """ + .AddRequestParameter("EntityLogicalName", CustomApiParameterType.String) + .AddRequestParameter("EntityId", CustomApiParameterType.Guid) + .AddRequestParameter("Count", CustomApiParameterType.Integer, isOptional: true) + """ + : string.Empty; + + var responseChain = withResponse + ? """ + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer) + .AddResponseProperty("ErrorMessage", CustomApiParameterType.String) + """ + : string.Empty; + + var returnType = withResponse ? "SomeApiResponse" : "void"; + var parameter = withRequest ? "SomeApiRequest request" : string.Empty; + var body = withResponse ? "=> new SomeApiResponse(0, string.Empty);" : "{ }"; + + return $$""" + using System; + using XrmPluginCore; + using XrmPluginCore.Enums; + using Microsoft.Extensions.DependencyInjection; + + namespace TestNamespace + { + public class SomeApi : Plugin + { + public SomeApi() + { + RegisterAPI(nameof(SomeApi), nameof(CallbackService.Handle)) + {{requestChain}}{{responseChain}} + ; + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public class CallbackService + { + public {{returnType}} Handle({{parameter}}) {{body}} + } + } + """; + } + /// /// Gets a complete compilable source with entity and plugin. /// diff --git a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md index 6213dee..502ee44 100644 --- a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md @@ -2,6 +2,9 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- +XPC4004 | XrmPluginCore.SourceGenerator | Error | XPC4004 Custom API handler method not found +XPC4005 | XrmPluginCore.SourceGenerator | Warning | XPC4005 Custom API handler signature mismatch (generated types don't exist) +XPC4006 | XrmPluginCore.SourceGenerator | Error | XPC4006 Custom API handler signature mismatch (generated types exist) ### Removed Rules diff --git a/XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerMethodNotFoundAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerMethodNotFoundAnalyzer.cs new file mode 100644 index 0000000..a0af67e --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerMethodNotFoundAnalyzer.cs @@ -0,0 +1,89 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; +using System.Linq; +using XrmPluginCore.SourceGenerator.Helpers; + +namespace XrmPluginCore.SourceGenerator.Analyzers; + +/// +/// Reports an error when a handler method referenced in a type-safe +/// RegisterAPI<TService>(name, handlerMethodName) call does not exist on the service type. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class CustomApiHandlerMethodNotFoundAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticDescriptors.CustomApiHandlerMethodNotFound); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + } + + private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + + if (!RegisterApiHelper.IsRegisterApiCall(invocation, out var genericName) || + !RegisterApiHelper.IsTypedHandlerOverload(invocation, context.SemanticModel)) + { + return; + } + + var arguments = invocation.ArgumentList.Arguments; + if (arguments.Count < 2) + { + return; + } + + var handlerArgument = arguments[1].Expression; + var methodName = RegisterApiHelper.GetHandlerMethodName(invocation); + if (methodName == null) + { + return; + } + + var serviceType = RegisterApiHelper.GetServiceType(genericName, context.SemanticModel); + if (serviceType == null) + { + return; + } + + var methods = TypeHelper.GetAllMethodsIncludingInherited(serviceType, methodName); + if (methods.Any()) + { + return; // Method exists + } + + var properties = ImmutableDictionary.CreateBuilder(); + properties.Add(Constants.PropertyServiceType, serviceType.Name); + properties.Add(Constants.PropertyMethodName, methodName); + + var generation = CustomApiGenerationContext.TryCreate(invocation, context.SemanticModel); + if (generation != null) + { + properties.Add(Constants.PropertyHasRequest, generation.HasRequest.ToString()); + properties.Add(Constants.PropertyHasResponse, generation.HasResponse.ToString()); + if (generation.HasRequest) + { + properties.Add(Constants.PropertyRequestTypeName, generation.RequestTypeFullName); + } + if (generation.HasResponse) + { + properties.Add(Constants.PropertyResponseTypeName, generation.ResponseTypeFullName); + } + } + + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.CustomApiHandlerMethodNotFound, + handlerArgument.GetLocation(), + properties.ToImmutable(), + methodName, + serviceType.Name)); + } +} diff --git a/XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerSignatureMismatchAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerSignatureMismatchAnalyzer.cs new file mode 100644 index 0000000..a2d7c98 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerSignatureMismatchAnalyzer.cs @@ -0,0 +1,152 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; +using System.Linq; +using XrmPluginCore.SourceGenerator.Helpers; + +namespace XrmPluginCore.SourceGenerator.Analyzers; + +/// +/// Reports when a Custom API handler method signature does not match the declared request parameters +/// and response properties. Reports XPC4005 (Warning) when the generated request/response types do not +/// exist yet, and XPC4006 (Error) when they do. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class CustomApiHandlerSignatureMismatchAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create( + DiagnosticDescriptors.CustomApiHandlerSignatureMismatch, + DiagnosticDescriptors.CustomApiHandlerSignatureMismatchError); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + } + + private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + + if (!RegisterApiHelper.IsRegisterApiCall(invocation, out var genericName) || + !RegisterApiHelper.IsTypedHandlerOverload(invocation, context.SemanticModel)) + { + return; + } + + var arguments = invocation.ArgumentList.Arguments; + if (arguments.Count < 2) + { + return; + } + + var handlerArgument = arguments[1].Expression; + var methodName = RegisterApiHelper.GetHandlerMethodName(invocation); + if (methodName == null) + { + return; + } + + var serviceType = RegisterApiHelper.GetServiceType(genericName, context.SemanticModel); + if (serviceType == null) + { + return; + } + + var methods = TypeHelper.GetAllMethodsIncludingInherited(serviceType, methodName); + if (!methods.Any()) + { + return; // Method doesn't exist - XPC4004 handles this + } + + var generation = CustomApiGenerationContext.TryCreate(invocation, context.SemanticModel); + if (generation == null) + { + return; + } + + if (methods.Any(m => SignatureMatches(m, generation))) + { + return; + } + + var expectedSignature = BuildSignatureDescription(methodName, generation); + var generatedTypesExist = DoGeneratedTypesExist(context, generation); + + var descriptor = generatedTypesExist + ? DiagnosticDescriptors.CustomApiHandlerSignatureMismatchError + : DiagnosticDescriptors.CustomApiHandlerSignatureMismatch; + + var properties = ImmutableDictionary.CreateBuilder(); + properties.Add(Constants.PropertyServiceType, serviceType.Name); + properties.Add(Constants.PropertyMethodName, methodName); + properties.Add(Constants.PropertyHasRequest, generation.HasRequest.ToString()); + properties.Add(Constants.PropertyHasResponse, generation.HasResponse.ToString()); + if (generation.HasRequest) + { + properties.Add(Constants.PropertyRequestTypeName, generation.RequestTypeFullName); + } + if (generation.HasResponse) + { + properties.Add(Constants.PropertyResponseTypeName, generation.ResponseTypeFullName); + } + + context.ReportDiagnostic(Diagnostic.Create( + descriptor, + handlerArgument.GetLocation(), + properties.ToImmutable(), + methodName, + expectedSignature)); + } + + private static bool SignatureMatches(IMethodSymbol method, CustomApiGenerationContext generation) + { + var expectedParamCount = generation.HasRequest ? 1 : 0; + if (method.Parameters.Length != expectedParamCount) + { + return false; + } + + if (generation.HasRequest && method.Parameters[0].Type.Name != generation.RequestClassName) + { + return false; + } + + // When response properties are declared, the handler must return the generated response so the + // wrapper can read its properties. When none are declared the return value is ignored. + if (generation.HasResponse && method.ReturnType.Name != generation.ResponseClassName) + { + return false; + } + + return true; + } + + private static bool DoGeneratedTypesExist(SyntaxNodeAnalysisContext context, CustomApiGenerationContext generation) + { + var compilation = context.SemanticModel.Compilation; + + if (generation.HasRequest && compilation.GetTypeByMetadataName(generation.RequestTypeFullName) == null) + { + return false; + } + + if (generation.HasResponse && compilation.GetTypeByMetadataName(generation.ResponseTypeFullName) == null) + { + return false; + } + + return true; + } + + private static string BuildSignatureDescription(string methodName, CustomApiGenerationContext generation) + { + var returnType = generation.HasResponse ? generation.ResponseClassName : "void"; + var parameter = generation.HasRequest ? $"{generation.RequestClassName} request" : string.Empty; + return $"{returnType} {methodName}({parameter})"; + } +} diff --git a/XrmPluginCore.SourceGenerator/Analyzers/PreferNameofAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/PreferNameofAnalyzer.cs index 3ada889..549af43 100644 --- a/XrmPluginCore.SourceGenerator/Analyzers/PreferNameofAnalyzer.cs +++ b/XrmPluginCore.SourceGenerator/Analyzers/PreferNameofAnalyzer.cs @@ -27,36 +27,19 @@ private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) { var invocation = (InvocationExpressionSyntax)context.Node; - // Check if this is a RegisterStep call - if (!RegisterStepHelper.IsRegisterStepCall(invocation, out var genericName)) + var handlerArgument = GetHandlerArgument(invocation, context.SemanticModel, out var serviceType); + if (handlerArgument == null) { return; } - // Check if there are at least 2 type arguments (TEntity, TService) - if (genericName.TypeArgumentList.Arguments.Count < 2) - { - return; - } - - // Check if there's a 3rd argument (the handler method) - var arguments = invocation.ArgumentList.Arguments; - if (arguments.Count < 3) - { - return; - } - - var handlerArgument = arguments[2].Expression; - - // Check if the 3rd argument is a string literal + // Check if the handler argument is a string literal if (handlerArgument is not LiteralExpressionSyntax literal || !literal.IsKind(SyntaxKind.StringLiteralExpression)) { return; } - // Get the service type name (TService) - var serviceType = genericName.TypeArgumentList.Arguments[1].ToString(); var methodName = literal.Token.ValueText; // Create diagnostic properties for the code fix @@ -73,4 +56,54 @@ private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) context.ReportDiagnostic(diagnostic); } + + /// + /// Returns the handler-method argument (and the service type name) for a RegisterStep call with a + /// handler argument, or a typed RegisterAPI<TService>(name, handlerMethodName) call. + /// Returns null when the invocation is neither. + /// + private static ExpressionSyntax GetHandlerArgument( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel, + out string serviceType) + { + serviceType = null; + + if (RegisterStepHelper.IsRegisterStepCall(invocation, out var stepGeneric)) + { + if (stepGeneric.TypeArgumentList.Arguments.Count < 2) + { + return null; + } + + var arguments = invocation.ArgumentList.Arguments; + if (arguments.Count < 3) + { + return null; + } + + serviceType = stepGeneric.TypeArgumentList.Arguments[1].ToString(); + return arguments[2].Expression; + } + + if (RegisterApiHelper.IsRegisterApiCall(invocation, out var apiGeneric) && + RegisterApiHelper.IsTypedHandlerOverload(invocation, semanticModel)) + { + if (apiGeneric.TypeArgumentList.Arguments.Count < 1) + { + return null; + } + + var arguments = invocation.ArgumentList.Arguments; + if (arguments.Count < 2) + { + return null; + } + + serviceType = apiGeneric.TypeArgumentList.Arguments[0].ToString(); + return arguments[1].Expression; + } + + return null; + } } diff --git a/XrmPluginCore.SourceGenerator/CodeFixes/CreateCustomApiHandlerMethodCodeFixProvider.cs b/XrmPluginCore.SourceGenerator/CodeFixes/CreateCustomApiHandlerMethodCodeFixProvider.cs new file mode 100644 index 0000000..cc8d7f3 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/CodeFixes/CreateCustomApiHandlerMethodCodeFixProvider.cs @@ -0,0 +1,177 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using XrmPluginCore.SourceGenerator.Helpers; + +namespace XrmPluginCore.SourceGenerator.CodeFixes; + +/// +/// Code fix that creates a missing Custom API handler method on the service type with the signature +/// matching the declared request parameters and response properties. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(CreateCustomApiHandlerMethodCodeFixProvider)), Shared] +public class CreateCustomApiHandlerMethodCodeFixProvider : CodeFixProvider +{ + public sealed override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create(DiagnosticDescriptors.CustomApiHandlerMethodNotFound.Id); + + public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root == null) + { + return; + } + + var diagnostic = context.Diagnostics.First(); + + if (!diagnostic.Properties.TryGetValue(Constants.PropertyMethodName, out var methodName)) + { + return; + } + + diagnostic.Properties.TryGetValue(Constants.PropertyHasRequest, out var hasRequestStr); + diagnostic.Properties.TryGetValue(Constants.PropertyHasResponse, out var hasResponseStr); + diagnostic.Properties.TryGetValue(Constants.PropertyRequestTypeName, out var requestTypeName); + diagnostic.Properties.TryGetValue(Constants.PropertyResponseTypeName, out var responseTypeName); + + var hasRequest = bool.TryParse(hasRequestStr, out var req) && req; + var hasResponse = bool.TryParse(hasResponseStr, out var resp) && resp; + + var title = $"Create method '{CustomApiHandlerSyntaxHelper.BuildSignatureTitle(methodName!, hasRequest, hasResponse, requestTypeName, responseTypeName)}'"; + + context.RegisterCodeFix( + CodeAction.Create( + title: title, + createChangedSolution: c => CreateMethodAsync(context.Document, diagnostic, methodName!, hasRequest, hasResponse, requestTypeName, responseTypeName, c), + equivalenceKey: nameof(CreateCustomApiHandlerMethodCodeFixProvider)), + diagnostic); + } + + private static async Task CreateMethodAsync( + Document document, + Diagnostic diagnostic, + string methodName, + bool hasRequest, + bool hasResponse, + string requestTypeName, + string responseTypeName, + CancellationToken cancellationToken) + { + var solution = document.Project.Solution; + + var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (semanticModel == null || root == null) + { + return solution; + } + + var diagnosticNode = root.FindNode(diagnostic.Location.SourceSpan); + var registerApiInvocation = diagnosticNode.AncestorsAndSelf() + .OfType() + .FirstOrDefault(i => RegisterApiHelper.IsRegisterApiCall(i, out _)); + if (registerApiInvocation == null) + { + return solution; + } + + var genericName = RegisterApiHelper.GetGenericName(registerApiInvocation); + var serviceType = RegisterApiHelper.GetServiceType(genericName, semanticModel) as INamedTypeSymbol; + if (serviceType == null) + { + return solution; + } + + var typeDeclaration = await FindTypeDeclarationAsync(solution, serviceType, cancellationToken); + if (typeDeclaration == null) + { + return solution; + } + + var typeDocument = solution.GetDocument(typeDeclaration.SyntaxTree); + if (typeDocument == null) + { + return solution; + } + + var typeRoot = await typeDeclaration.SyntaxTree.GetRootAsync(cancellationToken).ConfigureAwait(false); + + var isInterface = typeDeclaration is InterfaceDeclarationSyntax; + var methodDeclaration = CreateMethodDeclaration(methodName, hasRequest, hasResponse, requestTypeName, responseTypeName, isInterface); + + var newRoot = typeRoot.ReplaceNode(typeDeclaration, typeDeclaration.AddMembers(methodDeclaration)); + return solution.WithDocumentSyntaxRoot(typeDocument.Id, newRoot); + } + + private static MethodDeclarationSyntax CreateMethodDeclaration( + string methodName, + bool hasRequest, + bool hasResponse, + string requestTypeName, + string responseTypeName, + bool isInterface) + { + var method = SyntaxFactory.MethodDeclaration( + CustomApiHandlerSyntaxHelper.CreateReturnType(hasResponse, responseTypeName), + SyntaxFactory.Identifier(methodName)) + .WithParameterList(CustomApiHandlerSyntaxHelper.CreateParameterList(hasRequest, requestTypeName)); + + if (isInterface) + { + method = method + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)); + } + else + { + method = method + .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) + .WithBody(SyntaxFactory.Block( + SyntaxFactory.ThrowStatement( + SyntaxFactory.ObjectCreationExpression( + SyntaxFactory.ParseTypeName("System.NotImplementedException")) + .WithArgumentList(SyntaxFactory.ArgumentList())))); + } + + return method + .WithLeadingTrivia(SyntaxFactory.ElasticCarriageReturnLineFeed, SyntaxFactory.ElasticTab) + .WithTrailingTrivia(SyntaxFactory.ElasticCarriageReturnLineFeed); + } + + private static async Task FindTypeDeclarationAsync( + Solution solution, + INamedTypeSymbol typeSymbol, + CancellationToken cancellationToken) + { + foreach (var location in typeSymbol.Locations) + { + if (!location.IsInSource || location.SourceTree == null) + { + continue; + } + + var root = await location.SourceTree.GetRootAsync(cancellationToken).ConfigureAwait(false); + var node = root.FindNode(location.SourceSpan); + + var typeDeclaration = node.AncestorsAndSelf() + .OfType() + .FirstOrDefault(t => t is ClassDeclarationSyntax || t is InterfaceDeclarationSyntax); + + if (typeDeclaration != null) + { + return typeDeclaration; + } + } + + return null; + } +} diff --git a/XrmPluginCore.SourceGenerator/CodeFixes/FixCustomApiHandlerSignatureCodeFixProvider.cs b/XrmPluginCore.SourceGenerator/CodeFixes/FixCustomApiHandlerSignatureCodeFixProvider.cs new file mode 100644 index 0000000..2e661d2 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/CodeFixes/FixCustomApiHandlerSignatureCodeFixProvider.cs @@ -0,0 +1,170 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using XrmPluginCore.SourceGenerator.Helpers; + +namespace XrmPluginCore.SourceGenerator.CodeFixes; + +/// +/// Code fix that rewrites a Custom API handler method signature to match the declared request +/// parameters and response properties. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(FixCustomApiHandlerSignatureCodeFixProvider)), Shared] +public class FixCustomApiHandlerSignatureCodeFixProvider : CodeFixProvider +{ + public sealed override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create( + DiagnosticDescriptors.CustomApiHandlerSignatureMismatch.Id, + DiagnosticDescriptors.CustomApiHandlerSignatureMismatchError.Id); + + public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root == null) + { + return; + } + + var diagnostic = context.Diagnostics.First(); + + if (!diagnostic.Properties.TryGetValue(Constants.PropertyMethodName, out var methodName)) + { + return; + } + + diagnostic.Properties.TryGetValue(Constants.PropertyHasRequest, out var hasRequestStr); + diagnostic.Properties.TryGetValue(Constants.PropertyHasResponse, out var hasResponseStr); + diagnostic.Properties.TryGetValue(Constants.PropertyRequestTypeName, out var requestTypeName); + diagnostic.Properties.TryGetValue(Constants.PropertyResponseTypeName, out var responseTypeName); + + var hasRequest = bool.TryParse(hasRequestStr, out var req) && req; + var hasResponse = bool.TryParse(hasResponseStr, out var resp) && resp; + + var title = $"Fix signature to '{CustomApiHandlerSyntaxHelper.BuildSignatureTitle(methodName!, hasRequest, hasResponse, requestTypeName, responseTypeName)}'"; + + context.RegisterCodeFix( + CodeAction.Create( + title: title, + createChangedSolution: c => FixSignatureAsync(context.Document, diagnostic, methodName!, hasRequest, hasResponse, requestTypeName, responseTypeName, c), + equivalenceKey: nameof(FixCustomApiHandlerSignatureCodeFixProvider)), + diagnostic); + } + + private static async Task FixSignatureAsync( + Document document, + Diagnostic diagnostic, + string methodName, + bool hasRequest, + bool hasResponse, + string requestTypeName, + string responseTypeName, + CancellationToken cancellationToken) + { + var solution = document.Project.Solution; + + var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (semanticModel == null || root == null) + { + return solution; + } + + var diagnosticNode = root.FindNode(diagnostic.Location.SourceSpan); + var registerApiInvocation = diagnosticNode.AncestorsAndSelf() + .OfType() + .FirstOrDefault(i => RegisterApiHelper.IsRegisterApiCall(i, out _)); + if (registerApiInvocation == null) + { + return solution; + } + + var genericName = RegisterApiHelper.GetGenericName(registerApiInvocation); + var serviceType = RegisterApiHelper.GetServiceType(genericName, semanticModel) as INamedTypeSymbol; + if (serviceType == null) + { + return solution; + } + + var allMethods = new List(TypeHelper.GetAllMethodsIncludingInherited(serviceType, methodName)); + if (serviceType.TypeKind == TypeKind.Interface) + { + allMethods.AddRange(TypeHelper.FindImplementingMethods(semanticModel.Compilation, serviceType, methodName)); + } + + var newReturnType = CustomApiHandlerSyntaxHelper.CreateReturnType(hasResponse, responseTypeName); + var newParameters = CustomApiHandlerSyntaxHelper.CreateParameterList(hasRequest, requestTypeName); + + // Group method locations by document. + var locationsByTree = new Dictionary>(); + foreach (var method in allMethods) + { + foreach (var location in method.Locations) + { + if (!location.IsInSource || location.SourceTree == null) + { + continue; + } + + if (!locationsByTree.TryGetValue(location.SourceTree, out var list)) + { + list = new List(); + locationsByTree[location.SourceTree] = list; + } + + list.Add(location); + } + } + + foreach (var kvp in locationsByTree) + { + var methodDocument = solution.GetDocument(kvp.Key); + if (methodDocument == null) + { + continue; + } + + var methodRoot = await kvp.Key.GetRootAsync(cancellationToken).ConfigureAwait(false); + + var targets = new HashSet<(string typeName, string method)>(); + foreach (var location in kvp.Value) + { + var node = methodRoot.FindNode(location.SourceSpan); + var methodDecl = node.AncestorsAndSelf().OfType().FirstOrDefault(); + var containingType = methodDecl?.Ancestors().OfType().FirstOrDefault(); + if (methodDecl != null && containingType != null) + { + targets.Add((containingType.Identifier.Text, methodDecl.Identifier.Text)); + } + } + + var newRoot = methodRoot; + foreach (var target in targets) + { + var current = newRoot.DescendantNodes().OfType() + .FirstOrDefault(m => m.Identifier.Text == target.method && + m.Ancestors().OfType().FirstOrDefault()?.Identifier.Text == target.typeName); + if (current != null) + { + var updated = current + .WithParameterList(newParameters) + .WithReturnType(newReturnType.WithTrailingTrivia(SyntaxFactory.Space)); + newRoot = newRoot.ReplaceNode(current, updated); + } + } + + solution = solution.WithDocumentSyntaxRoot(methodDocument.Id, newRoot); + } + + return solution; + } +} diff --git a/XrmPluginCore.SourceGenerator/CodeGeneration/CustomApiClassGenerator.cs b/XrmPluginCore.SourceGenerator/CodeGeneration/CustomApiClassGenerator.cs new file mode 100644 index 0000000..92865a8 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/CodeGeneration/CustomApiClassGenerator.cs @@ -0,0 +1,202 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using XrmPluginCore.SourceGenerator.Helpers; +using XrmPluginCore.SourceGenerator.Models; +using static XrmPluginCore.SourceGenerator.CodeGeneration.Indent; + +namespace XrmPluginCore.SourceGenerator.CodeGeneration; + +/// +/// Generates the type-safe Request/Response classes and the ActionWrapper for a Custom API registration. +/// +internal static class CustomApiClassGenerator +{ + private static readonly HashSet CSharpKeywords = + [ + "abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char", "checked", + "class", "const", "continue", "decimal", "default", "delegate", "do", "double", "else", + "enum", "event", "explicit", "extern", "false", "finally", "fixed", "float", "for", + "foreach", "goto", "if", "implicit", "in", "int", "interface", "internal", "is", "lock", + "long", "namespace", "new", "null", "object", "operator", "out", "override", "params", + "private", "protected", "public", "readonly", "ref", "return", "sbyte", "sealed", "short", + "sizeof", "stackalloc", "static", "string", "struct", "switch", "this", "throw", "true", + "try", "typeof", "uint", "ulong", "unchecked", "unsafe", "ushort", "using", "virtual", + "void", "volatile", "while", + ]; + + /// + /// Generates a complete source file containing the Request, Response and ActionWrapper classes + /// for a Custom API registration. Returns null when there is no handler method to wire up. + /// + public static string GenerateCustomApiClasses(CustomApiMetadata metadata) + { + if (string.IsNullOrEmpty(metadata.HandlerMethodName)) + { + return null; + } + + var sb = new StringBuilder(512 + ((metadata.RequestParameters.Count + metadata.ResponseProperties.Count) * 80)); + + sb.Append(GetFileHeader(metadata.NullableAnnotationsEnabled)); + sb.AppendLine($"namespace {metadata.Namespace}"); + sb.AppendLine("{"); + + if (metadata.HasRequest) + { + GenerateRequestClass(sb, metadata); + } + + if (metadata.HasResponse) + { + GenerateResponseClass(sb, metadata); + } + + GenerateActionWrapperClass(sb, metadata); + + sb.AppendLine("}"); + + return sb.ToString(); + } + + public static string GenerateHintName(CustomApiMetadata metadata) => $"{metadata.UniqueId}.g.cs"; + + private static void GenerateRequestClass(StringBuilder sb, CustomApiMetadata metadata) + { + sb.AppendLine($"{L1}/// "); + sb.AppendLine($"{L1}/// Type-safe request for the {metadata.ApiName} Custom API."); + sb.AppendLine($"{L1}/// "); + sb.AppendLine($"{L1}[CompilerGenerated]"); + sb.AppendLine($"{L1}public sealed class {metadata.RequestClassName}"); + sb.AppendLine($"{L1}{{"); + + foreach (var parameter in metadata.RequestParameters) + { + sb.AppendLine($"{L2}public {EffectiveType(parameter, metadata.NullableAnnotationsEnabled)} {parameter.PropertyName} {{ get; set; }}"); + } + + sb.AppendLine($"{L1}}}"); + sb.AppendLine(); + } + + private static void GenerateResponseClass(StringBuilder sb, CustomApiMetadata metadata) + { + sb.AppendLine($"{L1}/// "); + sb.AppendLine($"{L1}/// Type-safe response for the {metadata.ApiName} Custom API."); + sb.AppendLine($"{L1}/// "); + sb.AppendLine($"{L1}[CompilerGenerated]"); + sb.AppendLine($"{L1}public sealed class {metadata.ResponseClassName}"); + sb.AppendLine($"{L1}{{"); + + foreach (var property in metadata.ResponseProperties) + { + sb.AppendLine($"{L2}public {EffectiveType(property, metadata.NullableAnnotationsEnabled)} {property.PropertyName} {{ get; set; }}"); + } + + sb.AppendLine(); + + var ctorParams = string.Join(", ", metadata.ResponseProperties.Select(p => $"{EffectiveType(p, metadata.NullableAnnotationsEnabled)} {ToParameterName(p.PropertyName)}")); + sb.AppendLine($"{L2}public {metadata.ResponseClassName}({ctorParams})"); + sb.AppendLine($"{L2}{{"); + foreach (var property in metadata.ResponseProperties) + { + sb.AppendLine($"{L3}{property.PropertyName} = {ToParameterName(property.PropertyName)};"); + } + sb.AppendLine($"{L2}}}"); + + sb.AppendLine($"{L1}}}"); + sb.AppendLine(); + } + + private static void GenerateActionWrapperClass(StringBuilder sb, CustomApiMetadata metadata) + { + sb.AppendLine($"{L1}/// "); + sb.AppendLine($"{L1}/// Generated action wrapper for {metadata.ServiceTypeName}.{metadata.HandlerMethodName}"); + sb.AppendLine($"{L1}/// "); + sb.AppendLine($"{L1}[CompilerGenerated]"); + sb.AppendLine($"{L1}internal sealed class {metadata.ActionWrapperClassName} : IActionWrapper"); + sb.AppendLine($"{L1}{{"); + sb.AppendLine($"{L2}public Action CreateAction()"); + sb.AppendLine($"{L2}{{"); + sb.AppendLine($"{L3}return serviceProvider =>"); + sb.AppendLine($"{L3}{{"); + sb.AppendLine($"{L4}var service = serviceProvider.GetRequiredService<{metadata.ServiceTypeFullName}>();"); + sb.AppendLine($"{L4}var context = serviceProvider.GetRequiredService();"); + + if (metadata.HasRequest) + { + sb.AppendLine(); + sb.AppendLine($"{L4}var request = new {metadata.RequestClassName}"); + sb.AppendLine($"{L4}{{"); + foreach (var parameter in metadata.RequestParameters) + { + var castType = EffectiveType(parameter, metadata.NullableAnnotationsEnabled); + sb.AppendLine( + $"{L5}{parameter.PropertyName} = context.InputParameters.Contains(\"{parameter.UniqueName}\") " + + $"? ({castType})context.InputParameters[\"{parameter.UniqueName}\"] : default,"); + } + sb.AppendLine($"{L4}}};"); + } + + sb.AppendLine(); + + var argument = metadata.HasRequest ? "request" : string.Empty; + if (metadata.HasResponse) + { + sb.AppendLine($"{L4}var response = service.{metadata.HandlerMethodName}({argument});"); + sb.AppendLine(); + foreach (var property in metadata.ResponseProperties) + { + sb.AppendLine($"{L4}context.OutputParameters[\"{property.UniqueName}\"] = response.{property.PropertyName};"); + } + } + else + { + sb.AppendLine($"{L4}service.{metadata.HandlerMethodName}({argument});"); + } + + sb.AppendLine($"{L3}}};"); + sb.AppendLine($"{L2}}}"); + sb.AppendLine($"{L1}}}"); + } + + /// + /// The CLR type to emit for a parameter. Reference types get a nullable ? only when NRT + /// annotations are enabled (so the output stays valid on NRT-off / C# 7.3 consumers); value types + /// already carry their own ? when optional. + /// + private static string EffectiveType(CustomApiParameterMetadata parameter, bool nullableEnabled) + { + if (CustomApiParameterTypeMapper.IsValueType(parameter.ParameterType)) + { + return parameter.ClrType; + } + + return nullableEnabled ? parameter.ClrType + "?" : parameter.ClrType; + } + + private static string ToParameterName(string propertyName) + { + if (string.IsNullOrEmpty(propertyName)) + { + return propertyName; + } + + var camelCase = char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1); + + // Escape keywords (and the rare case where lowercasing collided with a keyword) with a verbatim @. + return CSharpKeywords.Contains(camelCase) ? "@" + camelCase : camelCase; + } + + private static string GetFileHeader(bool nullableEnabled) => +$""" +// +{NullableHelper.FileDirective(nullableEnabled)} +using System; +using System.Runtime.CompilerServices; +using Microsoft.Xrm.Sdk; +using Microsoft.Extensions.DependencyInjection; +using XrmPluginCore; + +"""; +} diff --git a/XrmPluginCore.SourceGenerator/CodeGeneration/Indent.cs b/XrmPluginCore.SourceGenerator/CodeGeneration/Indent.cs index c97444a..61e2ab0 100644 --- a/XrmPluginCore.SourceGenerator/CodeGeneration/Indent.cs +++ b/XrmPluginCore.SourceGenerator/CodeGeneration/Indent.cs @@ -18,4 +18,7 @@ internal static class Indent /// Deep body level indentation (4 tabs) public static readonly string L4 = Tab + Tab + Tab + Tab; + + /// Nested body level indentation (5 tabs) + public static readonly string L5 = Tab + Tab + Tab + Tab + Tab; } diff --git a/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs b/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs index f547d02..41369b0 100644 --- a/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs +++ b/XrmPluginCore.SourceGenerator/CodeGeneration/WrapperClassGenerator.cs @@ -24,7 +24,7 @@ public static string GenerateWrapperClasses(PluginStepMetadata metadata) var sb = new StringBuilder(estimatedCapacity); // File header and using directives - sb.Append(GetFileHeader()); + sb.Append(GetFileHeader(metadata.NullableAnnotationsEnabled)); var namespaceToUse = metadata.RegistrationNamespace; @@ -133,10 +133,10 @@ public static string GenerateHintName(PluginStepMetadata metadata) #region Template Methods - private static string GetFileHeader() => -""" + private static string GetFileHeader(bool nullableEnabled) => +$""" // - +{Helpers.NullableHelper.FileDirective(nullableEnabled)} using System; using System.Linq; using System.Runtime.CompilerServices; diff --git a/XrmPluginCore.SourceGenerator/Constants.cs b/XrmPluginCore.SourceGenerator/Constants.cs index ad5586d..029b745 100644 --- a/XrmPluginCore.SourceGenerator/Constants.cs +++ b/XrmPluginCore.SourceGenerator/Constants.cs @@ -16,6 +16,16 @@ internal static class Constants public const string WithPostImageMethodName = "WithPostImage"; public const string AddImageMethodName = "AddImage"; + // Custom API method names + public const string RegisterApiMethodName = "RegisterAPI"; + public const string AddRequestParameterMethodName = "AddRequestParameter"; + public const string AddResponsePropertyMethodName = "AddResponseProperty"; + + // Custom API generated class name suffixes (combined with the sanitized API name) + public const string RequestClassSuffix = "Request"; + public const string ResponseClassSuffix = "Response"; + public const string ActionWrapperClassSuffix = "ActionWrapper"; + // Image types (concrete generated wrapper class names) public const string PreImageTypeName = "PreImage"; public const string PostImageTypeName = "PostImage"; @@ -32,4 +42,10 @@ internal static class Constants public const string PropertyHasPostImage = "HasPostImage"; public const string PropertyImageNamespace = "ImageNamespace"; public const string PropertyHasArguments = "HasArguments"; + + // Diagnostic property keys for Custom API handler fixes + public const string PropertyRequestTypeName = "RequestTypeName"; + public const string PropertyResponseTypeName = "ResponseTypeName"; + public const string PropertyHasRequest = "HasRequest"; + public const string PropertyHasResponse = "HasResponse"; } diff --git a/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs b/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs index f533dcc..d19e112 100644 --- a/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs +++ b/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs @@ -98,6 +98,33 @@ public static class DiagnosticDescriptors isEnabledByDefault: true, helpLinkUri: $"{HelpLinkBaseUri}/XPC4003.md"); + public static readonly DiagnosticDescriptor CustomApiHandlerMethodNotFound = new( + id: "XPC4004", + title: "Custom API handler method not found", + messageFormat: "Custom API handler method '{0}' not found on service type '{1}'", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + helpLinkUri: $"{HelpLinkBaseUri}/XPC4004.md"); + + public static readonly DiagnosticDescriptor CustomApiHandlerSignatureMismatch = new( + id: "XPC4005", + title: "Custom API handler signature does not match registered parameters", + messageFormat: "Custom API handler method '{0}' does not have the expected signature. Expected: {1}.", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + helpLinkUri: $"{HelpLinkBaseUri}/XPC4005.md"); + + public static readonly DiagnosticDescriptor CustomApiHandlerSignatureMismatchError = new( + id: "XPC4006", + title: "Custom API handler signature does not match registered parameters", + messageFormat: "Custom API handler method '{0}' does not have the expected signature. Expected: {1}.", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + helpLinkUri: $"{HelpLinkBaseUri}/XPC4006.md"); + public static readonly DiagnosticDescriptor SymbolResolutionFailed = new( id: "XPC5001", title: "Failed to resolve symbol", diff --git a/XrmPluginCore.SourceGenerator/Generators/CustomApiGenerator.cs b/XrmPluginCore.SourceGenerator/Generators/CustomApiGenerator.cs new file mode 100644 index 0000000..0728099 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Generators/CustomApiGenerator.cs @@ -0,0 +1,98 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; +using System.Threading; +using XrmPluginCore.SourceGenerator.CodeGeneration; +using XrmPluginCore.SourceGenerator.Helpers; +using XrmPluginCore.SourceGenerator.Models; +using XrmPluginCore.SourceGenerator.Parsers; + +namespace XrmPluginCore.SourceGenerator.Generators; + +/// +/// Incremental source generator that creates type-safe Request/Response/ActionWrapper classes for +/// Custom API registrations declared with RegisterAPI<TService>(name, handlerMethodName). +/// +[Generator] +public class CustomApiGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var metadata = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => IsCandidateClass(node), + transform: static (ctx, ct) => TransformToMetadata(ctx, ct)) + .Where(static m => m is not null); + + context.RegisterSourceOutput(metadata, (spc, m) => GenerateSourceFromMetadata(m, spc)); + } + + private static bool IsCandidateClass(SyntaxNode node) + => node is ClassDeclarationSyntax classDecl && + classDecl.Members.OfType().Any(); + + private static CustomApiMetadata TransformToMetadata(GeneratorSyntaxContext context, CancellationToken cancellationToken) + { + if (context.Node is not ClassDeclarationSyntax classDecl) + { + return null; + } + + if (cancellationToken.IsCancellationRequested) + { + return null; + } + + var semanticModel = context.SemanticModel; + if (!SyntaxHelper.InheritsFromPlugin(classDecl, semanticModel)) + { + return null; + } + + var metadata = CustomApiRegistrationParser.ParsePluginClass(classDecl, semanticModel); + if (metadata == null || string.IsNullOrEmpty(metadata.HandlerMethodName)) + { + return null; + } + + metadata.NullableAnnotationsEnabled = NullableHelper.AnnotationsEnabled(semanticModel.Compilation); + return metadata; + } + + private void GenerateSourceFromMetadata(CustomApiMetadata metadata, SourceProductionContext context) + { + if (metadata?.Diagnostics != null) + { + foreach (var diagnosticInfo in metadata.Diagnostics) + { + context.ReportDiagnostic(Diagnostic.Create(diagnosticInfo.Descriptor, Location.None, diagnosticInfo.MessageArgs)); + } + } + + if (string.IsNullOrEmpty(metadata?.HandlerMethodName)) + { + return; + } + + try + { + var sourceCode = CustomApiClassGenerator.GenerateCustomApiClasses(metadata); + if (sourceCode == null) + { + return; + } + + var hintName = CustomApiClassGenerator.GenerateHintName(metadata); + context.AddSource(hintName, SourceText.From(sourceCode, Encoding.UTF8)); + } + catch (System.Exception ex) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.GenerationError, + Location.None, + $"{ex.Message} | StackTrace: {ex.StackTrace}")); + } + } +} diff --git a/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs b/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs index 6674102..1a92f0d 100644 --- a/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs +++ b/XrmPluginCore.SourceGenerator/Generators/PluginImageGenerator.cs @@ -71,8 +71,10 @@ private static IEnumerable TransformToMetadata( if (!SyntaxHelper.InheritsFromPlugin(classDecl, semanticModel)) return null; + var nullableEnabled = Helpers.NullableHelper.AnnotationsEnabled(semanticModel.Compilation); + // Parse registrations (all heavy work here, in cacheable transform) - var metadataList = RegistrationParser.ParsePluginClass(classDecl, semanticModel); + var metadataList = RegistrationParser.ParsePluginClass(classDecl, semanticModel, nullableEnabled); if (!metadataList.Any()) return null; @@ -131,6 +133,7 @@ private static PluginStepMetadata MergeMetadata(IEnumerable ServiceTypeName = list[0].ServiceTypeName, ServiceTypeFullName = list[0].ServiceTypeFullName, HandlerMethodName = list[0].HandlerMethodName, + NullableAnnotationsEnabled = list[0].NullableAnnotationsEnabled, Images = [] }; diff --git a/XrmPluginCore.SourceGenerator/Helpers/CustomApiGenerationContext.cs b/XrmPluginCore.SourceGenerator/Helpers/CustomApiGenerationContext.cs new file mode 100644 index 0000000..962d5d3 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Helpers/CustomApiGenerationContext.cs @@ -0,0 +1,60 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Linq; +using XrmPluginCore.SourceGenerator.Parsers; + +namespace XrmPluginCore.SourceGenerator.Helpers; + +/// +/// Resolves the generated-class names and namespace for a type-safe Custom API registration, shared +/// between the analyzers (to detect signature mismatches) and the code-fix providers (to emit the +/// correct handler signature). +/// +internal sealed class CustomApiGenerationContext +{ + private CustomApiGenerationContext(string pluginNamespace, string sanitizedApiName, bool hasRequest, bool hasResponse) + { + PluginNamespace = pluginNamespace; + SanitizedApiName = sanitizedApiName; + HasRequest = hasRequest; + HasResponse = hasResponse; + } + + public string PluginNamespace { get; } + public string SanitizedApiName { get; } + public bool HasRequest { get; } + public bool HasResponse { get; } + + public string RequestClassName => $"{SanitizedApiName}{Constants.RequestClassSuffix}"; + public string ResponseClassName => $"{SanitizedApiName}{Constants.ResponseClassSuffix}"; + + public string RequestTypeFullName => $"{PluginNamespace}.{RequestClassName}"; + public string ResponseTypeFullName => $"{PluginNamespace}.{ResponseClassName}"; + + /// + /// Builds the context from a RegisterAPI invocation, or returns null when the API name cannot be + /// resolved as a compile-time constant. + /// + public static CustomApiGenerationContext TryCreate(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + { + var apiName = RegisterApiHelper.GetApiName(invocation, semanticModel); + if (string.IsNullOrEmpty(apiName)) + { + return null; + } + + var classDeclaration = invocation.Ancestors().OfType().FirstOrDefault(); + if (classDeclaration == null) + { + return null; + } + + var (hasRequest, hasResponse) = RegisterApiHelper.CheckForParameters(invocation); + + return new CustomApiGenerationContext( + classDeclaration.GetNamespace(), + IdentifierHelper.Sanitize(apiName), + hasRequest, + hasResponse); + } +} diff --git a/XrmPluginCore.SourceGenerator/Helpers/CustomApiHandlerSyntaxHelper.cs b/XrmPluginCore.SourceGenerator/Helpers/CustomApiHandlerSyntaxHelper.cs new file mode 100644 index 0000000..2fd5cf3 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Helpers/CustomApiHandlerSyntaxHelper.cs @@ -0,0 +1,51 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace XrmPluginCore.SourceGenerator.Helpers; + +/// +/// Builds the syntax pieces for a Custom API handler method +/// ({Response} {Method}({Request} request), adapting to the presence of request/response). +/// +internal static class CustomApiHandlerSyntaxHelper +{ + public const string RequestParameterName = "request"; + + public static TypeSyntax CreateReturnType(bool hasResponse, string responseTypeName) + { + return hasResponse && !string.IsNullOrEmpty(responseTypeName) + ? SyntaxFactory.ParseTypeName(responseTypeName) + : SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.VoidKeyword)); + } + + public static ParameterListSyntax CreateParameterList(bool hasRequest, string requestTypeName) + { + if (!hasRequest || string.IsNullOrEmpty(requestTypeName)) + { + return SyntaxFactory.ParameterList(); + } + + var parameter = SyntaxFactory.Parameter(SyntaxFactory.Identifier(RequestParameterName)) + .WithType(SyntaxFactory.ParseTypeName(requestTypeName)); + + return SyntaxFactory.ParameterList(SyntaxFactory.SingletonSeparatedList(parameter)); + } + + /// + /// Builds a one-line signature description (e.g. "FooResponse Handle(FooRequest request)") used in + /// code-fix titles. Uses the supplied type names verbatim. + /// + public static string BuildSignatureTitle(string methodName, bool hasRequest, bool hasResponse, string requestTypeName, string responseTypeName) + { + var returnType = hasResponse && !string.IsNullOrEmpty(responseTypeName) ? Short(responseTypeName) : "void"; + var parameter = hasRequest && !string.IsNullOrEmpty(requestTypeName) ? $"{Short(requestTypeName)} {RequestParameterName}" : string.Empty; + return $"{returnType} {methodName}({parameter})"; + } + + private static string Short(string typeName) + { + var lastDot = typeName.LastIndexOf('.'); + return lastDot >= 0 ? typeName.Substring(lastDot + 1) : typeName; + } +} diff --git a/XrmPluginCore.SourceGenerator/Helpers/CustomApiParameterTypeMapper.cs b/XrmPluginCore.SourceGenerator/Helpers/CustomApiParameterTypeMapper.cs new file mode 100644 index 0000000..ce490dc --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Helpers/CustomApiParameterTypeMapper.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; + +namespace XrmPluginCore.SourceGenerator.Helpers; + +/// +/// Maps XrmPluginCore.Enums.CustomApiParameterType values to the CLR type used for the +/// generated request/response properties. +/// +internal static class CustomApiParameterTypeMapper +{ + private static readonly Dictionary TypeByName = new() + { + ["Boolean"] = "bool", + ["DateTime"] = "System.DateTime", + ["Decimal"] = "decimal", + ["Entity"] = "Microsoft.Xrm.Sdk.Entity", + ["EntityCollection"] = "Microsoft.Xrm.Sdk.EntityCollection", + ["EntityReference"] = "Microsoft.Xrm.Sdk.EntityReference", + ["Float"] = "double", + ["Integer"] = "int", + ["Money"] = "Microsoft.Xrm.Sdk.Money", + ["Picklist"] = "Microsoft.Xrm.Sdk.OptionSetValue", + ["String"] = "string", + ["StringArray"] = "string[]", + ["Guid"] = "System.Guid", + }; + + // Index matches the underlying integer value of CustomApiParameterType, used as a fallback when + // the argument is a constant integer rather than a CustomApiParameterType.X member access. + private static readonly string[] NameByValue = + [ + "Boolean", "DateTime", "Decimal", "Entity", "EntityCollection", "EntityReference", + "Float", "Integer", "Money", "Picklist", "String", "StringArray", "Guid", + ]; + + /// + /// Value types (need a trailing ? when the corresponding request parameter is optional). + /// + private static readonly HashSet ValueTypeNames = + [ + "Boolean", "DateTime", "Decimal", "Float", "Integer", "Guid", + ]; + + public static string GetClrType(string parameterTypeName) + => parameterTypeName != null && TypeByName.TryGetValue(parameterTypeName, out var clr) ? clr : "object"; + + public static string GetNameForValue(int value) + => value >= 0 && value < NameByValue.Length ? NameByValue[value] : null; + + public static bool IsValueType(string parameterTypeName) + => parameterTypeName != null && ValueTypeNames.Contains(parameterTypeName); +} diff --git a/XrmPluginCore.SourceGenerator/Helpers/IdentifierHelper.cs b/XrmPluginCore.SourceGenerator/Helpers/IdentifierHelper.cs new file mode 100644 index 0000000..4503cf2 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Helpers/IdentifierHelper.cs @@ -0,0 +1,74 @@ +using System.Text; + +namespace XrmPluginCore.SourceGenerator.Helpers; + +/// +/// Sanitizes arbitrary strings into valid C# identifiers. +/// +/// The exact same rule is duplicated in the runtime +/// (XrmPluginCore.Helpers.IdentifierSanitizer) so that the runtime can discover the generated +/// ActionWrapper type by name. Keep the two implementations in sync. +/// +/// +internal static class IdentifierHelper +{ + public static string Sanitize(string value) + { + if (string.IsNullOrEmpty(value)) + { + return "_"; + } + + var sb = new StringBuilder(value.Length); + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + var isLetterOrUnderscore = char.IsLetter(c) || c == '_'; + var isDigit = char.IsDigit(c); + + if (isLetterOrUnderscore || (isDigit && i > 0)) + { + sb.Append(c); + } + else + { + sb.Append('_'); + } + } + + // An identifier cannot start with a digit + if (char.IsDigit(value[0])) + { + sb.Insert(0, '_'); + } + + return sb.ToString(); + } + + /// + /// Returns true when is already a valid C# identifier + /// (and therefore needs no sanitization). + /// + public static bool IsValidIdentifier(string value) + { + if (string.IsNullOrEmpty(value)) + { + return false; + } + + if (char.IsDigit(value[0])) + { + return false; + } + + foreach (var c in value) + { + if (!char.IsLetterOrDigit(c) && c != '_') + { + return false; + } + } + + return true; + } +} diff --git a/XrmPluginCore.SourceGenerator/Helpers/NullableHelper.cs b/XrmPluginCore.SourceGenerator/Helpers/NullableHelper.cs new file mode 100644 index 0000000..13dee75 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Helpers/NullableHelper.cs @@ -0,0 +1,43 @@ +using Microsoft.CodeAnalysis; + +namespace XrmPluginCore.SourceGenerator.Helpers; + +/// +/// Helpers for emitting nullable annotations in generated code in a way that stays backwards compatible +/// with consumers that have not enabled nullable reference types (including .NET Framework / C# 7.3, +/// where a nullable-reference ? or a #nullable directive is a compile error). +/// +/// Nullable value types (int?) are always safe and are never affected by this gate. +/// Only nullable reference annotations (string?) and the #nullable enable directive +/// are gated on the consuming compilation having NRT annotations enabled. +/// +/// +internal static class NullableHelper +{ + private static readonly SymbolDisplayFormat NonNullableReferenceFormat = + SymbolDisplayFormat.CSharpErrorMessageFormat.WithMiscellaneousOptions( + SymbolDisplayFormat.CSharpErrorMessageFormat.MiscellaneousOptions + & ~SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier); + + /// + /// True when the compilation has nullable reference-type annotations enabled + /// (<Nullable>annotations</Nullable> or enable), which also implies C# 8+. + /// + public static bool AnnotationsEnabled(Compilation compilation) + => (compilation.Options.NullableContextOptions & NullableContextOptions.Annotations) == NullableContextOptions.Annotations; + + /// + /// Renders a type for use in generated code. When NRT annotations are disabled, strips the + /// nullable-reference ? (so the output compiles without a #nullable context) while + /// keeping nullable value-type ? intact. + /// + public static string DisplayType(ITypeSymbol type, bool nullableEnabled) + => nullableEnabled ? type.ToDisplayString() : type.ToDisplayString(NonNullableReferenceFormat); + + /// + /// The #nullable enable directive line to place in a generated file, or an empty string when + /// NRT annotations are not enabled (so no directive is emitted on C# 7.3 / NRT-off consumers). + /// + public static string FileDirective(bool nullableEnabled) + => nullableEnabled ? "#nullable enable\n" : string.Empty; +} diff --git a/XrmPluginCore.SourceGenerator/Helpers/RegisterApiHelper.cs b/XrmPluginCore.SourceGenerator/Helpers/RegisterApiHelper.cs new file mode 100644 index 0000000..d666e72 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Helpers/RegisterApiHelper.cs @@ -0,0 +1,146 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Generic; +using System.Linq; + +namespace XrmPluginCore.SourceGenerator.Helpers; + +/// +/// Shared utilities for analyzing type-safe Custom API registrations +/// (RegisterAPI<TService>(name, handlerMethodName)) and their fluent +/// AddRequestParameter/AddResponseProperty chains. Mirrors . +/// +internal static class RegisterApiHelper +{ + /// + /// Checks if an invocation is a generic RegisterAPI<TService>(...) call and extracts the generic name. + /// + public static bool IsRegisterApiCall(InvocationExpressionSyntax invocation, out GenericNameSyntax genericName) + { + genericName = GetGenericName(invocation); + return genericName != null && genericName.Identifier.Text == Constants.RegisterApiMethodName; + } + + /// + /// Gets the GenericNameSyntax from a RegisterAPI call (handles this.RegisterAPI and bare forms). + /// + public static GenericNameSyntax GetGenericName(InvocationExpressionSyntax invocation) + { + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name is GenericNameSyntax generic) + { + return generic; + } + + if (invocation.Expression is GenericNameSyntax directGeneric) + { + return directGeneric; + } + + return null; + } + + /// + /// Determines whether the invocation binds to the typed handler-name overload + /// RegisterAPI<TService>(string name, string handlerMethodName) (vs the action overloads). + /// + public static bool IsTypedHandlerOverload(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + { + var method = ResolveMethod(invocation, semanticModel); + return method != null + && method.Parameters.Length == 2 + && method.Parameters[1].Type.SpecialType == SpecialType.System_String; + } + + /// + /// Resolves the API name (the first argument) as a compile-time constant string. Returns null when + /// it is not a constant. + /// + public static string GetApiName(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + { + var arguments = invocation.ArgumentList.Arguments; + if (arguments.Count < 1) + { + return null; + } + + var constant = semanticModel.GetConstantValue(arguments[0].Expression); + return constant.HasValue ? constant.Value as string : null; + } + + /// + /// Extracts the handler method name (second argument) from nameof()/string literal. + /// + public static string GetHandlerMethodName(InvocationExpressionSyntax invocation) + { + var arguments = invocation.ArgumentList.Arguments; + return arguments.Count < 2 ? null : RegisterStepHelper.GetMethodName(arguments[1].Expression); + } + + /// + /// Resolves the service type (the single generic argument) of a RegisterAPI call. + /// + public static ITypeSymbol GetServiceType(GenericNameSyntax genericName, SemanticModel semanticModel) + { + if (genericName == null || genericName.TypeArgumentList.Arguments.Count < 1) + { + return null; + } + + return semanticModel.GetTypeInfo(genericName.TypeArgumentList.Arguments[0]).Type; + } + + /// + /// Finds all AddRequestParameter/AddResponseProperty invocations chained to a RegisterAPI call, + /// in source order. + /// + public static IEnumerable FindParameterInvocations(InvocationExpressionSyntax registerApiInvocation) + { + var parent = registerApiInvocation.Parent; + while (parent != null) + { + if (parent is InvocationExpressionSyntax invocation && + invocation.Expression is MemberAccessExpressionSyntax memberAccess) + { + var methodName = memberAccess.Name.Identifier.Text; + if (methodName == Constants.AddRequestParameterMethodName || + methodName == Constants.AddResponsePropertyMethodName) + { + yield return invocation; + } + } + parent = parent.Parent; + } + } + + /// + /// Counts the request parameters and response properties declared in the fluent chain. + /// + public static (bool hasRequest, bool hasResponse) CheckForParameters(InvocationExpressionSyntax registerApiInvocation) + { + var hasRequest = false; + var hasResponse = false; + + foreach (var invocation in FindParameterInvocations(registerApiInvocation)) + { + var methodName = ((MemberAccessExpressionSyntax)invocation.Expression).Name.Identifier.Text; + if (methodName == Constants.AddRequestParameterMethodName) + { + hasRequest = true; + } + else if (methodName == Constants.AddResponsePropertyMethodName) + { + hasResponse = true; + } + } + + return (hasRequest, hasResponse); + } + + private static IMethodSymbol ResolveMethod(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + { + var symbolInfo = semanticModel.GetSymbolInfo(invocation); + return symbolInfo.Symbol as IMethodSymbol + ?? symbolInfo.CandidateSymbols.OfType().FirstOrDefault(); + } +} diff --git a/XrmPluginCore.SourceGenerator/Models/CustomApiMetadata.cs b/XrmPluginCore.SourceGenerator/Models/CustomApiMetadata.cs new file mode 100644 index 0000000..7a3266d --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Models/CustomApiMetadata.cs @@ -0,0 +1,136 @@ +using System.Collections.Generic; +using System.Linq; +using XrmPluginCore.SourceGenerator.Helpers; + +namespace XrmPluginCore.SourceGenerator.Models; + +/// +/// Represents metadata about a type-safe Custom API registration +/// (RegisterAPI<TService>(name, handlerMethodName)). +/// +internal sealed class CustomApiMetadata +{ + public string ApiName { get; set; } + public string Namespace { get; set; } + public string PluginClassName { get; set; } + + public string ServiceTypeName { get; set; } + public string ServiceTypeFullName { get; set; } + public string HandlerMethodName { get; set; } + + /// Whether the consuming compilation has nullable reference-type annotations enabled. + public bool NullableAnnotationsEnabled { get; set; } + + public List RequestParameters { get; set; } = []; + public List ResponseProperties { get; set; } = []; + + /// + /// Diagnostics to report for this registration. Not included in equality comparison. + /// + public List Diagnostics { get; set; } = []; + + /// The API name sanitized into a valid C# identifier, used to name generated classes. + public string SanitizedApiName => IdentifierHelper.Sanitize(ApiName); + + public string RequestClassName => $"{SanitizedApiName}{Constants.RequestClassSuffix}"; + public string ResponseClassName => $"{SanitizedApiName}{Constants.ResponseClassSuffix}"; + public string ActionWrapperClassName => $"{SanitizedApiName}{Constants.ActionWrapperClassSuffix}"; + + public bool HasRequest => RequestParameters.Any(); + public bool HasResponse => ResponseProperties.Any(); + + /// + /// Gets a unique identifier for the generated source file. + /// + public string UniqueId => + $"{Namespace?.Replace(".", "_")}_{PluginClassName}_{SanitizedApiName}_CustomApi"; + + public override bool Equals(object obj) + { + if (obj is CustomApiMetadata other) + { + return ApiName == other.ApiName + && Namespace == other.Namespace + && PluginClassName == other.PluginClassName + && ServiceTypeName == other.ServiceTypeName + && ServiceTypeFullName == other.ServiceTypeFullName + && HandlerMethodName == other.HandlerMethodName + && NullableAnnotationsEnabled == other.NullableAnnotationsEnabled + && RequestParameters.SequenceEqual(other.RequestParameters) + && ResponseProperties.SequenceEqual(other.ResponseProperties); + } + return false; + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = (hash * 31) + (ApiName?.GetHashCode() ?? 0); + hash = (hash * 31) + (Namespace?.GetHashCode() ?? 0); + hash = (hash * 31) + (PluginClassName?.GetHashCode() ?? 0); + hash = (hash * 31) + (ServiceTypeName?.GetHashCode() ?? 0); + hash = (hash * 31) + (ServiceTypeFullName?.GetHashCode() ?? 0); + hash = (hash * 31) + (HandlerMethodName?.GetHashCode() ?? 0); + hash = (hash * 31) + NullableAnnotationsEnabled.GetHashCode(); + foreach (var p in RequestParameters) + { + hash = (hash * 31) + p.GetHashCode(); + } + foreach (var p in ResponseProperties) + { + hash = (hash * 31) + p.GetHashCode(); + } + return hash; + } + } +} + +/// +/// Represents a single Custom API request parameter or response property. +/// +internal sealed class CustomApiParameterMetadata +{ + /// The declared unique name (the InputParameters/OutputParameters dictionary key). + public string UniqueName { get; set; } + + /// The generated C# property name (sanitized ). + public string PropertyName { get; set; } + + /// The CustomApiParameterType member name (e.g. "String", "Guid"). + public string ParameterType { get; set; } + + /// The CLR type used for the generated property (already nullable when optional). + public string ClrType { get; set; } + + /// Whether the request parameter is optional (response properties are always false). + public bool IsOptional { get; set; } + + public override bool Equals(object obj) + { + if (obj is CustomApiParameterMetadata other) + { + return UniqueName == other.UniqueName + && PropertyName == other.PropertyName + && ParameterType == other.ParameterType + && ClrType == other.ClrType + && IsOptional == other.IsOptional; + } + return false; + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = (hash * 31) + (UniqueName?.GetHashCode() ?? 0); + hash = (hash * 31) + (PropertyName?.GetHashCode() ?? 0); + hash = (hash * 31) + (ParameterType?.GetHashCode() ?? 0); + hash = (hash * 31) + (ClrType?.GetHashCode() ?? 0); + hash = (hash * 31) + IsOptional.GetHashCode(); + return hash; + } + } +} diff --git a/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs b/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs index 9436ac9..665d894 100644 --- a/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs +++ b/XrmPluginCore.SourceGenerator/Models/PluginStepMetadata.cs @@ -37,6 +37,9 @@ internal sealed class PluginStepMetadata /// public string HandlerMethodName { get; set; } + /// Whether the consuming compilation has nullable reference-type annotations enabled. + public bool NullableAnnotationsEnabled { get; set; } + /// /// Diagnostics to report for this plugin step. Not included in equality comparison. /// @@ -70,7 +73,8 @@ public override bool Equals(object obj) && Namespace == other.Namespace && ServiceTypeName == other.ServiceTypeName && ServiceTypeFullName == other.ServiceTypeFullName - && HandlerMethodName == other.HandlerMethodName; + && HandlerMethodName == other.HandlerMethodName + && NullableAnnotationsEnabled == other.NullableAnnotationsEnabled; } return false; } @@ -89,6 +93,7 @@ public override int GetHashCode() hash = (hash * 31) + (ServiceTypeName?.GetHashCode() ?? 0); hash = (hash * 31) + (ServiceTypeFullName?.GetHashCode() ?? 0); hash = (hash * 31) + (HandlerMethodName?.GetHashCode() ?? 0); + hash = (hash * 31) + NullableAnnotationsEnabled.GetHashCode(); foreach (var img in Images) { hash = (hash * 31) + img.GetHashCode(); diff --git a/XrmPluginCore.SourceGenerator/Parsers/CustomApiRegistrationParser.cs b/XrmPluginCore.SourceGenerator/Parsers/CustomApiRegistrationParser.cs new file mode 100644 index 0000000..1c70808 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Parsers/CustomApiRegistrationParser.cs @@ -0,0 +1,230 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Generic; +using System.Linq; +using XrmPluginCore.SourceGenerator.Helpers; +using XrmPluginCore.SourceGenerator.Models; + +namespace XrmPluginCore.SourceGenerator.Parsers; + +/// +/// Parses plugin class syntax to extract type-safe Custom API registration metadata. +/// +internal static class CustomApiRegistrationParser +{ + /// + /// Parses a plugin class and extracts the type-safe Custom API registration, if any. + /// Returns null when the class declares no RegisterAPI<TService>(name, handlerMethodName) call. + /// + public static CustomApiMetadata ParsePluginClass( + ClassDeclarationSyntax classDeclaration, + SemanticModel semanticModel) + { + var hasExplicitConstructors = classDeclaration.Members + .OfType() + .Any(); + + var constructor = classDeclaration.Members + .OfType() + .FirstOrDefault(c => c.ParameterList.Parameters.Count == 0); + + // If the class has explicit constructors but no parameterless one, the registration pipeline + // cannot run (mirrors RegistrationParser / XPC2001). + if (constructor == null && hasExplicitConstructors) + { + return null; + } + + if (constructor == null) + { + return null; + } + + foreach (var invocation in FindRegisterApiInvocations(constructor)) + { + if (!RegisterApiHelper.IsRegisterApiCall(invocation, out var genericName) || + !RegisterApiHelper.IsTypedHandlerOverload(invocation, semanticModel)) + { + continue; + } + + return ParseRegisterApiInvocation(invocation, genericName, semanticModel, classDeclaration); + } + + return null; + } + + private static CustomApiMetadata ParseRegisterApiInvocation( + InvocationExpressionSyntax invocation, + GenericNameSyntax genericName, + SemanticModel semanticModel, + ClassDeclarationSyntax classDeclaration) + { + var serviceType = RegisterApiHelper.GetServiceType(genericName, semanticModel); + if (serviceType == null) + { + return null; + } + + var apiName = RegisterApiHelper.GetApiName(invocation, semanticModel); + if (string.IsNullOrEmpty(apiName)) + { + // Cannot determine the API name as a constant => cannot name generated classes. + return null; + } + + var metadata = new CustomApiMetadata + { + ApiName = apiName, + Namespace = classDeclaration.GetNamespace(), + PluginClassName = classDeclaration.Identifier.Text, + ServiceTypeName = serviceType.Name, + ServiceTypeFullName = serviceType.ToDisplayString(), + HandlerMethodName = RegisterApiHelper.GetHandlerMethodName(invocation), + }; + + foreach (var parameterCall in RegisterApiHelper.FindParameterInvocations(invocation)) + { + var methodName = ((MemberAccessExpressionSyntax)parameterCall.Expression).Name.Identifier.Text; + var isRequest = methodName == Constants.AddRequestParameterMethodName; + + var parameter = ParseParameterInvocation(parameterCall, semanticModel, isRequest); + if (parameter == null) + { + continue; + } + + if (isRequest) + { + metadata.RequestParameters.Add(parameter); + } + else + { + metadata.ResponseProperties.Add(parameter); + } + } + + return metadata; + } + + private static CustomApiParameterMetadata ParseParameterInvocation( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel, + bool isRequest) + { + var boundArguments = BindArguments(invocation, semanticModel); + + // uniqueName (first parameter) - must be a compile-time constant string. + if (!boundArguments.TryGetValue("uniqueName", out var uniqueNameExpr)) + { + return null; + } + + var uniqueNameConstant = semanticModel.GetConstantValue(uniqueNameExpr); + if (!uniqueNameConstant.HasValue || uniqueNameConstant.Value is not string uniqueName || string.IsNullOrEmpty(uniqueName)) + { + return null; + } + + // type (CustomApiParameterType member access, or constant integer fallback). + var parameterType = boundArguments.TryGetValue("type", out var typeExpr) + ? GetParameterTypeName(typeExpr, semanticModel) + : null; + + var isOptional = isRequest + && boundArguments.TryGetValue("isOptional", out var optionalExpr) + && semanticModel.GetConstantValue(optionalExpr) is { HasValue: true, Value: true }; + + var clrType = CustomApiParameterTypeMapper.GetClrType(parameterType); + if (isOptional && CustomApiParameterTypeMapper.IsValueType(parameterType)) + { + clrType += "?"; + } + + return new CustomApiParameterMetadata + { + UniqueName = uniqueName, + PropertyName = IdentifierHelper.Sanitize(uniqueName), + ParameterType = parameterType, + ClrType = clrType, + IsOptional = isOptional, + }; + } + + /// + /// Binds each argument expression to its parameter name (honoring named and positional arguments). + /// + private static Dictionary BindArguments( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel) + { + var result = new Dictionary(); + + var symbolInfo = semanticModel.GetSymbolInfo(invocation); + var method = symbolInfo.Symbol as IMethodSymbol + ?? symbolInfo.CandidateSymbols.OfType().FirstOrDefault(); + if (method == null) + { + return result; + } + + var arguments = invocation.ArgumentList.Arguments; + var positional = 0; + foreach (var argument in arguments) + { + IParameterSymbol parameter; + if (argument.NameColon != null) + { + var name = argument.NameColon.Name.Identifier.Text; + parameter = method.Parameters.FirstOrDefault(p => p.Name == name); + } + else + { + parameter = positional < method.Parameters.Length ? method.Parameters[positional] : null; + positional++; + } + + if (parameter != null) + { + result[parameter.Name] = argument.Expression; + } + } + + return result; + } + + /// + /// Extracts the CustomApiParameterType member name from CustomApiParameterType.String or a + /// constant integer. + /// + private static string GetParameterTypeName(ExpressionSyntax expression, SemanticModel semanticModel) + { + if (expression is MemberAccessExpressionSyntax memberAccess) + { + return memberAccess.Name.Identifier.Text; + } + + var constant = semanticModel.GetConstantValue(expression); + if (constant is { HasValue: true, Value: int value }) + { + return CustomApiParameterTypeMapper.GetNameForValue(value); + } + + return null; + } + + private static IEnumerable FindRegisterApiInvocations(ConstructorDeclarationSyntax constructor) + { + IEnumerable nodes = constructor.Body != null + ? constructor.Body.DescendantNodes() + : constructor.ExpressionBody?.DescendantNodes() ?? Enumerable.Empty(); + + foreach (var invocation in nodes.OfType()) + { + if (RegisterApiHelper.IsRegisterApiCall(invocation, out _)) + { + yield return invocation; + } + } + } +} diff --git a/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs b/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs index 65f8d1f..86b6cdc 100644 --- a/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs +++ b/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs @@ -18,7 +18,8 @@ internal static class RegistrationParser /// public static IEnumerable ParsePluginClass( ClassDeclarationSyntax classDeclaration, - SemanticModel semanticModel) + SemanticModel semanticModel, + bool nullableEnabled = false) { // Check if plugin class has a parameterless constructor var hasParameterlessConstructor = classDeclaration.Members @@ -48,7 +49,7 @@ public static IEnumerable ParsePluginClass( // Find all RegisterStep invocations foreach (var registerStep in SyntaxHelper.FindRegisterStepInvocations(constructor)) { - var metadata = ParseRegisterStepInvocation(registerStep, semanticModel, classDeclaration); + var metadata = ParseRegisterStepInvocation(registerStep, semanticModel, classDeclaration, nullableEnabled); if (metadata != null) { yield return metadata; @@ -62,7 +63,8 @@ public static IEnumerable ParsePluginClass( private static PluginStepMetadata ParseRegisterStepInvocation( InvocationExpressionSyntax registerStepInvocation, SemanticModel semanticModel, - ClassDeclarationSyntax classDeclaration) + ClassDeclarationSyntax classDeclaration, + bool nullableEnabled) { // Get the symbol info to extract type arguments var symbolInfo = semanticModel.GetSymbolInfo(registerStepInvocation); @@ -91,7 +93,8 @@ private static PluginStepMetadata ParseRegisterStepInvocation( EntityTypeName = entityType.Name, EntityTypeFullName = entityType.ToDisplayString(), Namespace = classDeclaration.GetNamespace(), - PluginClassName = classDeclaration.Identifier.Text + PluginClassName = classDeclaration.Identifier.Text, + NullableAnnotationsEnabled = nullableEnabled }; // Extract service type from generic parameter TService (if present) @@ -119,7 +122,7 @@ private static PluginStepMetadata ParseRegisterStepInvocation( // Find image calls foreach (var imageCall in SyntaxHelper.FindImageInvocations(registerStepInvocation)) { - var imageMetadata = ParseImageInvocation(imageCall, entityType); + var imageMetadata = ParseImageInvocation(imageCall, entityType, nullableEnabled); if (imageMetadata != null) { metadata.Images.Add(imageMetadata); @@ -137,7 +140,8 @@ private static PluginStepMetadata ParseRegisterStepInvocation( /// private static ImageMetadata ParseImageInvocation( InvocationExpressionSyntax imageInvocation, - ITypeSymbol entityType) + ITypeSymbol entityType, + bool nullableEnabled) { if (imageInvocation.Expression is not MemberAccessExpressionSyntax memberAccess) return null; @@ -224,7 +228,7 @@ private static ImageMetadata ParseImageInvocation( if (treatAsAttribute) { // This is an attribute - var attrMetadata = GetAttributeMetadata(value, entityType); + var attrMetadata = GetAttributeMetadata(value, entityType, nullableEnabled); if (attrMetadata != null) { imageMetadata.Attributes.Add(attrMetadata); @@ -248,7 +252,7 @@ private static ImageMetadata ParseImageInvocation( if (!imageMetadata.Attributes.Any() && (methodName == Constants.WithPreImageMethodName || methodName == Constants.WithPostImageMethodName)) { - imageMetadata.Attributes.AddRange(GetAllEntityAttributes(entityType)); + imageMetadata.Attributes.AddRange(GetAllEntityAttributes(entityType, nullableEnabled)); } return imageMetadata.Attributes.Any() ? imageMetadata : null; @@ -258,12 +262,12 @@ private static ImageMetadata ParseImageInvocation( /// Gets all attribute metadata for all entity properties that have an AttributeLogicalName attribute. /// Used for full entity images where no specific attributes are specified. /// - private static IEnumerable GetAllEntityAttributes(ITypeSymbol entityType) + private static IEnumerable GetAllEntityAttributes(ITypeSymbol entityType, bool nullableEnabled) { return entityType.GetMembers() .OfType() .Where(p => p.GetAttributes().Any(a => a.AttributeClass?.Name == Constants.LogicalNameAttributeName)) - .Select(p => GetAttributeMetadata(p.Name, entityType)) + .Select(p => GetAttributeMetadata(p.Name, entityType, nullableEnabled)) .Where(a => a != null); } @@ -272,7 +276,8 @@ private static IEnumerable GetAllEntityAttributes(ITypeSymbol /// private static AttributeMetadata GetAttributeMetadata( string propertyName, - ITypeSymbol entityType) + ITypeSymbol entityType, + bool nullableEnabled) { // Find the property in the entity type var property = entityType.GetMembers(propertyName) @@ -292,7 +297,9 @@ private static AttributeMetadata GetAttributeMetadata( { PropertyName = propertyName, LogicalName = logicalName, - TypeName = property.Type.ToDisplayString(), + // Strip nullable-reference '?' when the consumer hasn't enabled NRT, so generated code stays + // valid on NRT-off / C# 7.3 consumers. Nullable value-type '?' is preserved. + TypeName = NullableHelper.DisplayType(property.Type, nullableEnabled), XmlDocumentation = xmlDoc }; } diff --git a/XrmPluginCore.SourceGenerator/rules/XPC4004.md b/XrmPluginCore.SourceGenerator/rules/XPC4004.md new file mode 100644 index 0000000..8e5b4ee --- /dev/null +++ b/XrmPluginCore.SourceGenerator/rules/XPC4004.md @@ -0,0 +1,53 @@ +# XPC4004: Custom API handler method not found + +## Severity + +Error + +## Description + +This rule reports when the handler method referenced in a type-safe `RegisterAPI(name, handlerMethodName)` call does not exist on the specified service type. The source generator validates that the method exists so it can emit the `ActionWrapper` that invokes it with the generated request/response types. + +## ❌ Example of violation + +```csharp +public class SomeCustomApi : Plugin +{ + public SomeCustomApi() + { + // XPC4004: Custom API handler method 'Handle' not found on service type 'CallbackService' + RegisterAPI(nameof(SomeCustomApi), nameof(CallbackService.Handle)) + .AddRequestParameter("EntityId", CustomApiParameterType.Guid) + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer); + } +} + +public class CallbackService +{ + // No 'Handle' method +} +``` + +## ✅ How to fix + +Add the missing handler method with the generated request/response types: + +```csharp +public class CallbackService +{ + public SomeCustomApiResponse Handle(SomeCustomApiRequest request) + { + return new SomeCustomApiResponse(200); + } +} +``` + +## Code fix available + +IDEs supporting Roslyn analyzers offer a code fix to create the missing method on the service type with the signature matching the declared request parameters and response properties. + +## See also + +- [XPC4005: Custom API handler signature mismatch (types don't exist)](XPC4005.md) +- [XPC4006: Custom API handler signature mismatch (types exist)](XPC4006.md) +- [XPC3001: Prefer nameof over string literal](XPC3001.md) diff --git a/XrmPluginCore.SourceGenerator/rules/XPC4005.md b/XrmPluginCore.SourceGenerator/rules/XPC4005.md new file mode 100644 index 0000000..08e690a --- /dev/null +++ b/XrmPluginCore.SourceGenerator/rules/XPC4005.md @@ -0,0 +1,66 @@ +# XPC4005: Custom API handler signature does not match registered parameters + +## Severity + +Warning + +## Description + +This rule reports when a Custom API handler method's signature does not match the request parameters and response properties declared with `AddRequestParameter()`/`AddResponseProperty()`, **and** the generated request/response types do not exist yet. + +The handler must: +- Accept the generated `{ApiName}Request` parameter when request parameters are declared (and no parameter otherwise). +- Return the generated `{ApiName}Response` when response properties are declared (and may return `void` otherwise). + +This is a **warning** so the initial build can succeed and the source generator can create the request/response types. Once they exist, this diagnostic escalates to [XPC4006](XPC4006.md) (Error). + +## ❌ Example of violation + +```csharp +public class SomeCustomApi : Plugin +{ + public SomeCustomApi() + { + // XPC4005: Custom API handler method 'Handle' does not have the expected signature. + // Expected: SomeCustomApiResponse Handle(SomeCustomApiRequest request). + RegisterAPI(nameof(SomeCustomApi), nameof(CallbackService.Handle)) + .AddRequestParameter("EntityId", CustomApiParameterType.Guid) + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer); + } +} + +public class CallbackService +{ + public void Handle() { } // Missing request parameter and response return type +} +``` + +## ✅ How to fix + +```csharp +public class CallbackService +{ + public SomeCustomApiResponse Handle(SomeCustomApiRequest request) + { + return new SomeCustomApiResponse(200); + } +} +``` + +### Signature rules + +| Declared | Expected signature | +|----------|--------------------| +| Request + response | `{ApiName}Response Method({ApiName}Request request)` | +| Request only | `void Method({ApiName}Request request)` | +| Response only | `{ApiName}Response Method()` | +| Neither | `void Method()` | + +## Code fix available + +IDEs offer a code fix to update the handler signature to match the declared parameters. + +## See also + +- [XPC4006: Custom API handler signature mismatch (types exist)](XPC4006.md) +- [XPC4004: Custom API handler method not found](XPC4004.md) diff --git a/XrmPluginCore.SourceGenerator/rules/XPC4006.md b/XrmPluginCore.SourceGenerator/rules/XPC4006.md new file mode 100644 index 0000000..69ea9f8 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/rules/XPC4006.md @@ -0,0 +1,49 @@ +# XPC4006: Custom API handler signature does not match registered parameters (types exist) + +## Severity + +Error + +## Description + +This is the error-severity counterpart of [XPC4005](XPC4005.md). It is reported when a Custom API handler method's signature does not match the declared request parameters and response properties **and** the generated `{ApiName}Request`/`{ApiName}Response` types already exist in the compilation. + +Because the generated types exist, leaving the signature wrong would cause the generated `ActionWrapper` to fail to compile, so this is an error rather than a warning. + +## ❌ Example of violation + +```csharp +public class CallbackService +{ + // XPC4006: Expected: SomeCustomApiResponse Handle(SomeCustomApiRequest request). + public string Handle(SomeCustomApiRequest request) => ""; // Wrong return type +} +``` + +## ✅ How to fix + +Match the handler signature to the declared parameters: + +```csharp +public class CallbackService +{ + public SomeCustomApiResponse Handle(SomeCustomApiRequest request) + { + return new SomeCustomApiResponse(200); + } +} +``` + +## Relationship with XPC4005 + +- **XPC4005 (Warning)**: Reported when the generated types don't exist yet, so the initial build can succeed. +- **XPC4006 (Error)**: Reported when the generated types exist but the signature is still wrong. + +## Code fix available + +IDEs offer a code fix to update the handler signature to match the declared parameters. + +## See also + +- [XPC4005: Custom API handler signature mismatch (types don't exist)](XPC4005.md) +- [XPC4004: Custom API handler method not found](XPC4004.md) diff --git a/XrmPluginCore.Tests/TestCustomApis/TypeSafe/TypeSafeCustomApi.cs b/XrmPluginCore.Tests/TestCustomApis/TypeSafe/TypeSafeCustomApi.cs new file mode 100644 index 0000000..8945632 --- /dev/null +++ b/XrmPluginCore.Tests/TestCustomApis/TypeSafe/TypeSafeCustomApi.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.DependencyInjection; +using XrmPluginCore.Enums; + +namespace XrmPluginCore.Tests.TestCustomApis.TypeSafe; + +/// +/// Test Custom API using the type-safe API. The Request/Response classes +/// (TypeSafeCustomApiRequest/TypeSafeCustomApiResponse) and the ActionWrapper are emitted by the +/// source generator from the AddRequestParameter/AddResponseProperty declarations below. +/// +public class TypeSafeCustomApi : Plugin +{ + public TypeSafeCustomApi() + { + RegisterAPI(nameof(TypeSafeCustomApi), nameof(TypeSafeCustomApiService.Handle)) + .AddRequestParameter("EntityLogicalName", CustomApiParameterType.String) + .AddRequestParameter("EntityId", CustomApiParameterType.Guid) + .AddRequestParameter("Count", CustomApiParameterType.Integer, isOptional: true) + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer) + .AddResponseProperty("ErrorMessage", CustomApiParameterType.String); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + services.AddScoped(); + return base.OnBeforeBuildServiceProvider(services); + } +} diff --git a/XrmPluginCore.Tests/TestCustomApis/TypeSafe/TypeSafeCustomApiService.cs b/XrmPluginCore.Tests/TestCustomApis/TypeSafe/TypeSafeCustomApiService.cs new file mode 100644 index 0000000..3ee4af6 --- /dev/null +++ b/XrmPluginCore.Tests/TestCustomApis/TypeSafe/TypeSafeCustomApiService.cs @@ -0,0 +1,17 @@ +namespace XrmPluginCore.Tests.TestCustomApis.TypeSafe; + +/// +/// Service for . Receives the strongly-typed request and returns the +/// strongly-typed response. Echoes request values into the response so tests can assert that the +/// generated wrapper read InputParameters and wrote OutputParameters correctly. +/// +public class TypeSafeCustomApiService +{ + public TypeSafeCustomApiResponse Handle(TypeSafeCustomApiRequest request) + { + var status = request.Count ?? -1; + var message = $"{request.EntityLogicalName}:{request.EntityId}"; + + return new TypeSafeCustomApiResponse(status, message); + } +} diff --git a/XrmPluginCore.Tests/TypeSafeCustomApiTests.cs b/XrmPluginCore.Tests/TypeSafeCustomApiTests.cs new file mode 100644 index 0000000..3cb2de2 --- /dev/null +++ b/XrmPluginCore.Tests/TypeSafeCustomApiTests.cs @@ -0,0 +1,93 @@ +using FluentAssertions; +using Microsoft.Xrm.Sdk; +using System; +using System.Linq; +using Xunit; +using XrmPluginCore.Enums; +using XrmPluginCore.Tests.Helpers; +using XrmPluginCore.Tests.TestCustomApis.TypeSafe; + +namespace XrmPluginCore.Tests; + +/// +/// End-to-end tests for the type-safe Custom API request/response wrappers. The generated +/// ActionWrapper marshals InputParameters into the request, invokes the handler, and writes the +/// returned response back into OutputParameters. +/// +public class TypeSafeCustomApiTests +{ + [Fact] + public void Execute_ShouldMarshalRequestAndResponse() + { + // Arrange + var customApi = new TypeSafeCustomApi(); + var mockProvider = new MockServiceProvider(); + + var entityId = Guid.NewGuid(); + mockProvider.SetupInputParameters(new ParameterCollection + { + { "EntityLogicalName", "account" }, + { "EntityId", entityId }, + { "Count", 42 }, + }); + + var outputParameters = new ParameterCollection(); + mockProvider.SetupOutputParameters(outputParameters); + + // Act + customApi.Execute(mockProvider.ServiceProvider); + + // Assert - the handler echoed the request into the response, proving both directions worked + outputParameters.Should().ContainKey("StatusCode"); + outputParameters["StatusCode"].Should().Be(42); + + outputParameters.Should().ContainKey("ErrorMessage"); + outputParameters["ErrorMessage"].Should().Be($"account:{entityId}"); + } + + [Fact] + public void Execute_ShouldUseDefault_WhenOptionalParameterMissing() + { + // Arrange - omit the optional "Count" request parameter + var customApi = new TypeSafeCustomApi(); + var mockProvider = new MockServiceProvider(); + + var entityId = Guid.NewGuid(); + mockProvider.SetupInputParameters(new ParameterCollection + { + { "EntityLogicalName", "contact" }, + { "EntityId", entityId }, + }); + + var outputParameters = new ParameterCollection(); + mockProvider.SetupOutputParameters(outputParameters); + + // Act + customApi.Execute(mockProvider.ServiceProvider); + + // Assert - missing optional int? maps to null, which the handler turns into -1 + outputParameters["StatusCode"].Should().Be(-1); + outputParameters["ErrorMessage"].Should().Be($"contact:{entityId}"); + } + + [Fact] + public void Registration_ShouldContainDeclaredParameters() + { + // Arrange + var customApi = new TypeSafeCustomApi(); + + // Act + var registration = customApi.GetRegistration(); + + // Assert + registration.Should().NotBeNull(); + registration!.Name.Should().Be("TypeSafeCustomApi"); + + registration.RequestParameters.Select(p => p.UniqueName) + .Should().BeEquivalentTo(["EntityLogicalName", "EntityId", "Count"]); + registration.RequestParameters.Single(p => p.UniqueName == "Count").IsOptional.Should().BeTrue(); + + registration.ResponseProperties.Select(p => p.UniqueName) + .Should().BeEquivalentTo(["StatusCode", "ErrorMessage"]); + } +} diff --git a/XrmPluginCore/CHANGELOG.md b/XrmPluginCore/CHANGELOG.md index 6fdd0b6..4071591 100644 --- a/XrmPluginCore/CHANGELOG.md +++ b/XrmPluginCore/CHANGELOG.md @@ -1,3 +1,10 @@ +### v1.4.0 - 30 June 2026 +* Add: Type-safe Custom API request/response wrappers. `RegisterAPI(name, handlerMethodName)` now generates `{ApiName}Request`/`{ApiName}Response` classes (named after the API, in the plugin's namespace) from the `AddRequestParameter`/`AddResponseProperty` declarations. The handler accepts the request and returns the response; a generated `ActionWrapper` marshals `InputParameters` into the request and the returned response into `OutputParameters`. When no request parameters are declared the handler takes no argument, and when no response properties are declared it returns `void`. +* Add: Error XPC4004: Custom API handler method not found (with code fix to create the method). +* Add: Warning XPC4005 / Error XPC4006: Custom API handler signature does not match the declared request parameters and response properties (with code fix to correct the signature). +* Add: XPC3001 (Prefer `nameof` over string literal) now also covers the Custom API handler argument. +* Fix: Generated code (images and Custom API request/response) now only emits nullable reference-type annotations (`string?`) and a `#nullable enable` directive when the consuming project has nullable reference types enabled. On projects without NRT (including .NET Framework / C# 7.3 defaults) the generated code is emitted without those annotations, keeping it compilable and warning-free. Nullable value types (`int?`) are always emitted. + ### v1.3.0 - 22 June 2026 * Add: `IPluginImage`, `IPluginImage`, `IPluginPreImage`/`IPluginPreImage` and `IPluginPostImage`/`IPluginPostImage` interfaces for generated images. Handler methods can now accept these interface types so functionality can be shared across the per-registration concrete image types. The generic variants expose a type-safe `Entity` property. * Add: Generated images (and `IPluginImage`) now always expose the record's `Id` (primary key) and `LogicalName`, since they are available on every entity image. diff --git a/XrmPluginCore/CustomApis/CustomApiConfigBuilder.cs b/XrmPluginCore/CustomApis/CustomApiConfigBuilder.cs index faa4390..27929d7 100644 --- a/XrmPluginCore/CustomApis/CustomApiConfigBuilder.cs +++ b/XrmPluginCore/CustomApis/CustomApiConfigBuilder.cs @@ -14,6 +14,11 @@ public class CustomApiConfigBuilder private CustomApiConfig Config { get; } + /// + /// The unique name of the Custom API being configured. + /// + public string Name => Config.Name; + public CustomApiConfigBuilder(string name) { Config = new CustomApiConfig() diff --git a/XrmPluginCore/CustomApis/CustomApiRegistration.cs b/XrmPluginCore/CustomApis/CustomApiRegistration.cs index 4fc4f75..4c5c738 100644 --- a/XrmPluginCore/CustomApis/CustomApiRegistration.cs +++ b/XrmPluginCore/CustomApis/CustomApiRegistration.cs @@ -9,8 +9,23 @@ public CustomApiRegistration(CustomApiConfigBuilder customApiConfig, Action + /// Creates a registration that defers to a source-generated ActionWrapper, discovered by naming + /// convention from the (sanitized) API name. Used by the type-safe + /// RegisterAPI<TService>(name, handlerMethodName) overload. + /// + public CustomApiRegistration(CustomApiConfigBuilder customApiConfig, string handlerMethodName) + { + ConfigBuilder = customApiConfig; + Action = null; + HandlerMethodName = handlerMethodName; + } + public CustomApiConfigBuilder ConfigBuilder { get; set; } public Action Action { get; set; } + + public string HandlerMethodName { get; } } } diff --git a/XrmPluginCore/Helpers/IdentifierSanitizer.cs b/XrmPluginCore/Helpers/IdentifierSanitizer.cs new file mode 100644 index 0000000..2936e8d --- /dev/null +++ b/XrmPluginCore/Helpers/IdentifierSanitizer.cs @@ -0,0 +1,48 @@ +using System.Text; + +namespace XrmPluginCore.Helpers +{ + /// + /// Sanitizes arbitrary strings into valid C# identifiers. + /// + /// The exact same rule is duplicated in the source generator + /// (XrmPluginCore.SourceGenerator.Helpers.IdentifierHelper) so that the runtime can discover the + /// generated ActionWrapper type by name. Keep the two implementations in sync. + /// + /// + internal static class IdentifierSanitizer + { + public static string Sanitize(string value) + { + if (string.IsNullOrEmpty(value)) + { + return "_"; + } + + var sb = new StringBuilder(value.Length); + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + var isLetterOrUnderscore = char.IsLetter(c) || c == '_'; + var isDigit = char.IsDigit(c); + + if (isLetterOrUnderscore || (isDigit && i > 0)) + { + sb.Append(c); + } + else + { + sb.Append('_'); + } + } + + // An identifier cannot start with a digit + if (char.IsDigit(value[0])) + { + sb.Insert(0, '_'); + } + + return sb.ToString(); + } + } +} diff --git a/XrmPluginCore/Plugin.cs b/XrmPluginCore/Plugin.cs index 4fafbb3..4569394 100644 --- a/XrmPluginCore/Plugin.cs +++ b/XrmPluginCore/Plugin.cs @@ -8,6 +8,7 @@ using XrmPluginCore.CustomApis; using XrmPluginCore.Enums; using XrmPluginCore.Extensions; +using XrmPluginCore.Helpers; using XrmPluginCore.Interfaces.CustomApi; using XrmPluginCore.Interfaces.Plugin; using XrmPluginCore.Plugins; @@ -271,11 +272,7 @@ protected PluginStepConfigBuilder RegisterStep( var builder = new PluginStepConfigBuilder(eventOperation, executionStage); var registration = new PluginStepRegistration( pluginStepConfig: builder, - action: action, - pluginClassName: ChildClassShortName, - entityTypeName: typeof(T).Name, - eventOperation: eventOperation, - executionStage: executionStage.ToString()); + action: action); RegisteredPluginSteps.Add(registration); return builder; } @@ -328,16 +325,17 @@ protected PluginStepConfigBuilder RegisterStep( { var builder = new PluginStepConfigBuilder(eventOperation, executionStage); + // Compute the generated ActionWrapper type name up front. Must match the namespace the source + // generator emits: {Namespace}.PluginRegistrations.{PluginClassName}.{Entity}{Operation}{Stage}. + var wrapperTypeName = + $"{GetType().Namespace}.PluginRegistrations.{ChildClassShortName}." + + $"{typeof(TEntity).Name}{eventOperation}{executionStage}.ActionWrapper"; + var registration = new PluginStepRegistration( pluginStepConfig: builder, action: null, - pluginClassName: ChildClassShortName, - entityTypeName: typeof(TEntity).Name, - eventOperation: eventOperation, - executionStage: executionStage.ToString(), - serviceTypeName: typeof(TService).Name, - serviceTypeFullName: typeof(TService).FullName, - handlerMethodName: handlerMethodName); + handlerMethodName: handlerMethodName, + wrapperTypeName: wrapperTypeName); RegisteredPluginSteps.Add(registration); return builder; @@ -345,10 +343,11 @@ protected PluginStepConfigBuilder RegisterStep( private Action DiscoverGeneratedAction(PluginStepRegistration registration) { - // Build the wrapper type name using naming convention - // Format: {Namespace}.PluginRegistrations.{PluginClassName}.{Entity}{Operation}{Stage}.ActionWrapper - var wrapperTypeName = $"{GetType().Namespace}.PluginRegistrations.{registration.PluginClassName}." + - $"{registration.EntityTypeName}{registration.EventOperation}{registration.ExecutionStage}.ActionWrapper"; + // The wrapper type name is computed at registration time (the single source of truth), so plugin + // steps and Custom APIs share this discovery path regardless of their naming convention. + var wrapperTypeName = registration.WrapperTypeName; + if (string.IsNullOrEmpty(wrapperTypeName)) + return null; var wrapperType = GetType().Assembly.GetType(wrapperTypeName); if (wrapperType == null) @@ -385,6 +384,36 @@ protected CustomApiConfigBuilder RegisterAPI(string name, Action action(sp.GetRequiredService())); } + /// + /// + /// Register a CustomAPI with a handler method name.
+ /// The source generator emits type-safe Request/Response classes (named after the API) + /// from the AddRequestParameter/AddResponseProperty calls, and an ActionWrapper that + /// marshals the execution context's InputParameters into the request and the returned response into + /// the OutputParameters. The handler method must accept the generated request (when request parameters + /// are declared) and return the generated response (when response properties are declared). + ///
+ /// + /// Use nameof(TService.MethodName) for compile-time safety. + /// + ///
+ /// The service type that contains the handler method + /// The unique name of the Custom API + /// The name of the handler method (use nameof(TService.MethodName)) + /// If called multiple times in the same class + protected CustomApiConfigBuilder RegisterAPI(string name, string handlerMethodName) + { + if (RegisteredCustomApi != null) + { + throw new InvalidOperationException("You cannot register multiple CustomAPIs in the same class"); + } + + var configBuilder = new CustomApiConfigBuilder(name); + RegisteredCustomApi = new CustomApiRegistration(configBuilder, handlerMethodName); + + return configBuilder; + } + /// /// /// Register a CustomAPI with the given name and action.
@@ -415,13 +444,21 @@ private PluginStepRegistration GetMatchingRegistration(IPluginExecutionContext c // If no plugin step found and we have a CustomAPI, return a registration with that action if (pluginStepRegistration == null && RegisteredCustomApi != null) { + // Type-safe Custom APIs have no action; they defer to a source-generated ActionWrapper + // discovered by naming convention from the (sanitized) API name in the plugin's namespace. + string handlerMethodName = null; + string wrapperTypeName = null; + if (RegisteredCustomApi.Action == null && RegisteredCustomApi.HandlerMethodName != null) + { + handlerMethodName = RegisteredCustomApi.HandlerMethodName; + wrapperTypeName = $"{GetType().Namespace}.{IdentifierSanitizer.Sanitize(RegisteredCustomApi.ConfigBuilder.Name)}ActionWrapper"; + } + return new PluginStepRegistration( pluginStepConfig: null, action: RegisteredCustomApi.Action, - pluginClassName: ChildClassShortName, - entityTypeName: null, - eventOperation: null, - executionStage: null); + handlerMethodName: handlerMethodName, + wrapperTypeName: wrapperTypeName); } return pluginStepRegistration; diff --git a/XrmPluginCore/Plugins/PluginStepRegistration.cs b/XrmPluginCore/Plugins/PluginStepRegistration.cs index f1a01ab..1387d13 100644 --- a/XrmPluginCore/Plugins/PluginStepRegistration.cs +++ b/XrmPluginCore/Plugins/PluginStepRegistration.cs @@ -5,29 +5,19 @@ namespace XrmPluginCore.Plugins; internal sealed class PluginStepRegistration( IPluginStepConfigBuilder pluginStepConfig, Action action, - string pluginClassName, - string entityTypeName, - string eventOperation, - string executionStage, - string serviceTypeName = null, - string serviceTypeFullName = null, - string handlerMethodName = null) + string handlerMethodName = null, + string wrapperTypeName = null) { public IPluginStepConfigBuilder ConfigBuilder { get; } = pluginStepConfig; public Action Action { get; } = action; - public string PluginClassName { get; } = pluginClassName; - - public string EntityTypeName { get; } = entityTypeName; - - public string EventOperation { get; } = eventOperation; - - public string ExecutionStage { get; } = executionStage; - - public string ServiceTypeName { get; } = serviceTypeName; - - public string ServiceTypeFullName { get; } = serviceTypeFullName; - public string HandlerMethodName { get; } = handlerMethodName; + + /// + /// The fully qualified type name of the source-generated ActionWrapper to discover and invoke + /// when is null. Computed at registration time for both plugin steps and Custom + /// APIs, so runtime discovery has a single source of truth regardless of naming convention. + /// + public string WrapperTypeName { get; } = wrapperTypeName; } From 1471311b8db5dbe30ab739ac778decb1d7098c91 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Tue, 30 Jun 2026 10:44:55 +0200 Subject: [PATCH 2/8] Use SyntaxFacts.GetKeywordKind for handler parameter escaping Replace the hardcoded C# keyword list in CustomApiClassGenerator with SyntaxFacts.GetKeywordKind, which is the purpose-built Roslyn API: it matches exactly the reserved keywords that need verbatim @-escaping and excludes contextual keywords (value/select/from), which are valid identifiers. Adds a test covering the escaping (e.g. "Class" -> "@class"). Co-Authored-By: Claude via Conducktor --- .../CustomApiClassGenerationTests.cs | 40 +++++++++++++++++++ .../CodeGeneration/CustomApiClassGenerator.cs | 20 ++-------- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/XrmPluginCore.SourceGenerator.Tests/GenerationTests/CustomApiClassGenerationTests.cs b/XrmPluginCore.SourceGenerator.Tests/GenerationTests/CustomApiClassGenerationTests.cs index 5fd5f9f..85016fc 100644 --- a/XrmPluginCore.SourceGenerator.Tests/GenerationTests/CustomApiClassGenerationTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/GenerationTests/CustomApiClassGenerationTests.cs @@ -92,6 +92,46 @@ public void Should_Return_Void_When_No_Response_Properties() generated.Should().NotContain("context.OutputParameters["); } + [Fact] + public void Should_Escape_Response_Constructor_Parameter_That_Is_A_Keyword() + { + // A response property whose camelCased name is a reserved keyword must be emitted as a verbatim + // identifier in the constructor (e.g. "Class" -> "@class"), otherwise the generated code won't compile. + const string source = """ + using XrmPluginCore; + using XrmPluginCore.Enums; + using Microsoft.Extensions.DependencyInjection; + + namespace TestNamespace + { + public class SomeApi : Plugin + { + public SomeApi() + { + RegisterAPI(nameof(SomeApi), nameof(CallbackService.Handle)) + .AddResponseProperty("Class", CustomApiParameterType.String); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + => services.AddScoped(); + } + + public class CallbackService + { + public SomeApiResponse Handle() => new SomeApiResponse(null); + } + } + """; + + var result = GeneratorTestHelper.RunCustomApiGenerator(CompilationHelper.CreateCompilation(source)); + + var generated = result.GeneratedTrees[0].GetText().ToString(); + + // Constructor parameter escaped, assignment uses the same verbatim name + generated.Should().Contain("public SomeApiResponse(string? @class)"); + generated.Should().Contain("Class = @class;"); + } + [Fact] public void Should_Not_Annotate_Reference_Types_When_Nullable_Disabled() { diff --git a/XrmPluginCore.SourceGenerator/CodeGeneration/CustomApiClassGenerator.cs b/XrmPluginCore.SourceGenerator/CodeGeneration/CustomApiClassGenerator.cs index 92865a8..39b50ab 100644 --- a/XrmPluginCore.SourceGenerator/CodeGeneration/CustomApiClassGenerator.cs +++ b/XrmPluginCore.SourceGenerator/CodeGeneration/CustomApiClassGenerator.cs @@ -1,6 +1,6 @@ -using System.Collections.Generic; using System.Linq; using System.Text; +using Microsoft.CodeAnalysis.CSharp; using XrmPluginCore.SourceGenerator.Helpers; using XrmPluginCore.SourceGenerator.Models; using static XrmPluginCore.SourceGenerator.CodeGeneration.Indent; @@ -12,19 +12,6 @@ namespace XrmPluginCore.SourceGenerator.CodeGeneration; ///
internal static class CustomApiClassGenerator { - private static readonly HashSet CSharpKeywords = - [ - "abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char", "checked", - "class", "const", "continue", "decimal", "default", "delegate", "do", "double", "else", - "enum", "event", "explicit", "extern", "false", "finally", "fixed", "float", "for", - "foreach", "goto", "if", "implicit", "in", "int", "interface", "internal", "is", "lock", - "long", "namespace", "new", "null", "object", "operator", "out", "override", "params", - "private", "protected", "public", "readonly", "ref", "return", "sbyte", "sealed", "short", - "sizeof", "stackalloc", "static", "string", "struct", "switch", "this", "throw", "true", - "try", "typeof", "uint", "ulong", "unchecked", "unsafe", "ushort", "using", "virtual", - "void", "volatile", "while", - ]; - /// /// Generates a complete source file containing the Request, Response and ActionWrapper classes /// for a Custom API registration. Returns null when there is no handler method to wire up. @@ -184,8 +171,9 @@ private static string ToParameterName(string propertyName) var camelCase = char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1); - // Escape keywords (and the rare case where lowercasing collided with a keyword) with a verbatim @. - return CSharpKeywords.Contains(camelCase) ? "@" + camelCase : camelCase; + // Escape reserved keywords with a verbatim @. SyntaxFacts.GetKeywordKind returns None for + // non-keywords and for contextual keywords (which are valid identifiers and need no escaping). + return SyntaxFacts.GetKeywordKind(camelCase) != SyntaxKind.None ? "@" + camelCase : camelCase; } private static string GetFileHeader(bool nullableEnabled) => From 0f14ffd8be4740fee4e6ff2e18fd269824114e97 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Tue, 30 Jun 2026 11:03:24 +0200 Subject: [PATCH 3/8] Harden generated-type handling: keyword escaping, this-qualified ctor, namespace-aware type matching Addresses code review feedback for the Custom API generator, and applies the same namespace-aware fix to the existing image analyzer for consistency. Custom API generator: - Escape reserved-keyword property names (sanitized unique names like "event"/"namespace") at every emission site: property declarations, the request object initializer, the response constructor assignment, and the response member access in the wrapper. Contextual keywords (yield/value) are left unescaped since they are valid identifiers. - Qualify the response constructor assignment with `this.` so it sets the property instead of self-assigning the parameter when the property and parameter resolve to the same identifier (lowercase unique names) - which previously left the property unset and warned CS1717. Signature analyzers (Custom API and images): - Match the generated request/response/image types by namespace + name instead of short name only, so a same-named type in a different namespace is no longer mistaken for the generated type. Falls back to name-only when the expected namespace can't be resolved. Tests for each case, including a compile check of the generated output. Co-Authored-By: Claude via Conducktor --- .../CustomApiHandlerDiagnosticsTests.cs | 43 +++++++++++++ .../DiagnosticReportingTests.cs | 52 ++++++++++++++++ .../CustomApiClassGenerationTests.cs | 62 ++++++++++++++++++- ...stomApiHandlerSignatureMismatchAnalyzer.cs | 20 +++++- .../HandlerSignatureMismatchAnalyzer.cs | 24 ++++--- .../CodeGeneration/CustomApiClassGenerator.cs | 25 +++++--- 6 files changed, 202 insertions(+), 24 deletions(-) diff --git a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/CustomApiHandlerDiagnosticsTests.cs b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/CustomApiHandlerDiagnosticsTests.cs index ac0e5d7..98b4d0f 100644 --- a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/CustomApiHandlerDiagnosticsTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/CustomApiHandlerDiagnosticsTests.cs @@ -68,6 +68,49 @@ public async Task Should_Not_Report_When_Signature_Matches() diagnostics.Should().NotContain(d => d.Id == "XPC4005" || d.Id == "XPC4006"); } + [Fact] + public async Task Should_Report_Mismatch_When_Handler_Uses_Same_Named_Type_From_Different_Namespace() + { + // The handler's request/response types share the generated types' short names but live in a + // different namespace, so they are NOT the generated types and must be reported as a mismatch. + const string source = """ + using XrmPluginCore; + using XrmPluginCore.Enums; + using Microsoft.Extensions.DependencyInjection; + + namespace Other + { + public sealed class SomeApiRequest { } + public sealed class SomeApiResponse { } + } + + namespace TestNamespace + { + public class SomeApi : Plugin + { + public SomeApi() + { + RegisterAPI(nameof(SomeApi), nameof(CallbackService.Handle)) + .AddRequestParameter("EntityId", CustomApiParameterType.Guid) + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + => services.AddScoped(); + } + + public class CallbackService + { + public Other.SomeApiResponse Handle(Other.SomeApiRequest request) => new Other.SomeApiResponse(); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source, new CustomApiHandlerSignatureMismatchAnalyzer()); + + diagnostics.Should().Contain(d => d.Id == "XPC4005" || d.Id == "XPC4006"); + } + [Fact] public async Task Should_Report_XPC3001_For_String_Literal_Handler() { diff --git a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs index 21106ae..fec4594 100644 --- a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs @@ -446,6 +446,58 @@ public void Process(PostImage post, PreImage pre) { } errorDiagnostics.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Warning); } + [Fact] + public async Task Should_Report_XPC4002_When_Image_Parameter_Is_Same_Named_Type_From_Different_Namespace() + { + // The handler's PreImage shares the generated wrapper's short name but lives in a different + // namespace, so it is NOT the generated wrapper and must be reported as a mismatch (matched by + // namespace + name, not name alone). + const string source = """ + using XrmPluginCore; + using XrmPluginCore.Enums; + using Microsoft.Extensions.DependencyInjection; + using XrmPluginCore.Tests.Context.BusinessDomain; + using WrongNamespace; + + namespace WrongNamespace + { + public sealed class PreImage { } + } + + namespace TestNamespace + { + public class TestPlugin : Plugin + { + public TestPlugin() + { + RegisterStep(EventOperation.Update, ExecutionStage.PostOperation, + nameof(ITestService.Process)) + .WithPreImage(x => x.Name); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + return services.AddScoped(); + } + } + + public interface ITestService + { + void Process(PreImage pre); + } + + public class TestService : ITestService + { + public void Process(PreImage pre) { } + } + } + """; + + var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new HandlerSignatureMismatchAnalyzer()); + + diagnostics.Should().Contain(d => d.Id == "XPC4002" || d.Id == "XPC4003"); + } + [Fact] public async Task Should_Report_XPC3003_When_WithPreImage_Used_With_Invocation_Syntax() { diff --git a/XrmPluginCore.SourceGenerator.Tests/GenerationTests/CustomApiClassGenerationTests.cs b/XrmPluginCore.SourceGenerator.Tests/GenerationTests/CustomApiClassGenerationTests.cs index 85016fc..beb261a 100644 --- a/XrmPluginCore.SourceGenerator.Tests/GenerationTests/CustomApiClassGenerationTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/GenerationTests/CustomApiClassGenerationTests.cs @@ -40,8 +40,8 @@ public void Should_Generate_Response_Class_With_Constructor() generated.Should().Contain("public int StatusCode { get; set; }"); generated.Should().Contain("public string? ErrorMessage { get; set; }"); generated.Should().Contain("public SomeApiResponse(int statusCode, string? errorMessage)"); - generated.Should().Contain("StatusCode = statusCode;"); - generated.Should().Contain("ErrorMessage = errorMessage;"); + generated.Should().Contain("this.StatusCode = statusCode;"); + generated.Should().Contain("this.ErrorMessage = errorMessage;"); } [Fact] @@ -129,7 +129,63 @@ public class CallbackService // Constructor parameter escaped, assignment uses the same verbatim name generated.Should().Contain("public SomeApiResponse(string? @class)"); - generated.Should().Contain("Class = @class;"); + generated.Should().Contain("this.Class = @class;"); + } + + [Fact] + public void Should_Escape_Keyword_Property_Names_At_Every_Emission_Site() + { + // A unique name that sanitizes to a reserved keyword (e.g. "namespace"/"event") must be escaped + // everywhere it is emitted as an identifier: property declarations, the request object + // initializer, the response constructor assignment, and the response member access in the wrapper. + const string source = """ + using XrmPluginCore; + using XrmPluginCore.Enums; + using Microsoft.Extensions.DependencyInjection; + + namespace TestNamespace + { + public class SomeApi : Plugin + { + public SomeApi() + { + RegisterAPI(nameof(SomeApi), nameof(CallbackService.Handle)) + .AddRequestParameter("namespace", CustomApiParameterType.String) + .AddResponseProperty("event", CustomApiParameterType.String); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + => services.AddScoped(); + } + + public class CallbackService + { + public SomeApiResponse Handle(SomeApiRequest request) => new SomeApiResponse(null); + } + } + """; + + var result = GeneratorTestHelper.RunCustomApiGenerator(CompilationHelper.CreateCompilation(source)); + var generated = result.GeneratedTrees[0].GetText().ToString(); + + // Property declarations + generated.Should().Contain("public string? @namespace { get; set; }"); + generated.Should().Contain("public string? @event { get; set; }"); + // Request object initializer (dictionary key stays the raw unique name) + generated.Should().Contain("@namespace = context.InputParameters.Contains(\"namespace\")"); + // Response constructor assignment is this-qualified so it sets the property, not the parameter + generated.Should().Contain("this.@event = @event;"); + // Response member access in the wrapper + generated.Should().Contain("context.OutputParameters[\"event\"] = response.@event;"); + + // The generated source compiles (no keyword-as-identifier errors) + using var ms = new System.IO.MemoryStream(); + var emit = result.OutputCompilation.Emit(ms); + var errors = emit.Diagnostics + .Where(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error) + .Select(d => $"{d.Id}: {d.GetMessage()}") + .ToArray(); + errors.Should().BeEmpty(); } [Fact] diff --git a/XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerSignatureMismatchAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerSignatureMismatchAnalyzer.cs index a2d7c98..84c1026 100644 --- a/XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerSignatureMismatchAnalyzer.cs +++ b/XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerSignatureMismatchAnalyzer.cs @@ -111,14 +111,14 @@ private static bool SignatureMatches(IMethodSymbol method, CustomApiGenerationCo return false; } - if (generation.HasRequest && method.Parameters[0].Type.Name != generation.RequestClassName) + if (generation.HasRequest && !IsGeneratedType(method.Parameters[0].Type, generation.PluginNamespace, generation.RequestClassName)) { return false; } // When response properties are declared, the handler must return the generated response so the // wrapper can read its properties. When none are declared the return value is ignored. - if (generation.HasResponse && method.ReturnType.Name != generation.ResponseClassName) + if (generation.HasResponse && !IsGeneratedType(method.ReturnType, generation.PluginNamespace, generation.ResponseClassName)) { return false; } @@ -126,6 +126,22 @@ private static bool SignatureMatches(IMethodSymbol method, CustomApiGenerationCo return true; } + /// + /// Matches the generated request/response type by both namespace and name, so a same-named type in a + /// different namespace is not mistaken for the generated one. + /// + private static bool IsGeneratedType(ITypeSymbol type, string expectedNamespace, string expectedName) + { + if (type == null || type.Name != expectedName) + { + return false; + } + + var ns = type.ContainingNamespace; + var namespaceName = ns == null || ns.IsGlobalNamespace ? string.Empty : ns.ToDisplayString(); + return namespaceName == expectedNamespace; + } + private static bool DoGeneratedTypesExist(SyntaxNodeAnalysisContext context, CustomApiGenerationContext generation) { var compilation = context.SemanticModel.Compilation; diff --git a/XrmPluginCore.SourceGenerator/Analyzers/HandlerSignatureMismatchAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/HandlerSignatureMismatchAnalyzer.cs index 5811ca6..9f8b7b5 100644 --- a/XrmPluginCore.SourceGenerator/Analyzers/HandlerSignatureMismatchAnalyzer.cs +++ b/XrmPluginCore.SourceGenerator/Analyzers/HandlerSignatureMismatchAnalyzer.cs @@ -89,8 +89,12 @@ private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) var entityTypeSyntax = genericName.TypeArgumentList.Arguments[0]; var entityType = context.SemanticModel.GetTypeInfo(entityTypeSyntax).Type; + // Compute expected namespace for generated types (used both for matching the concrete wrapper by + // namespace and for the severity/code-fix below). + var expectedNamespace = RegisterStepHelper.GetExpectedImageNamespace(invocation, genericName, context.SemanticModel); + // Check if any overload matches the expected signature - var hasMatchingOverload = methods.Any(method => SignatureMatches(method, hasPreImage, hasPostImage, entityType)); + var hasMatchingOverload = methods.Any(method => SignatureMatches(method, hasPreImage, hasPostImage, entityType, expectedNamespace)); if (hasMatchingOverload) { return; @@ -99,9 +103,6 @@ private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) // Build expected signature description var expectedSignature = SyntaxFactoryHelper.BuildSignatureDescription(hasPreImage, hasPostImage); - // Compute expected namespace for generated types - var expectedNamespace = RegisterStepHelper.GetExpectedImageNamespace(invocation, genericName, context.SemanticModel); - // Determine if generated types exist to choose appropriate diagnostic severity var generatedTypesExist = DoGeneratedTypesExist( context, @@ -169,7 +170,7 @@ private static bool DoGeneratedTypesExist( return true; } - private static bool SignatureMatches(IMethodSymbol method, bool hasPreImage, bool hasPostImage, ITypeSymbol entityType) + private static bool SignatureMatches(IMethodSymbol method, bool hasPreImage, bool hasPostImage, ITypeSymbol entityType, string expectedNamespace) { var parameters = method.Parameters; var expectedParamCount = (hasPreImage ? 1 : 0) + (hasPostImage ? 1 : 0); @@ -188,7 +189,7 @@ private static bool SignatureMatches(IMethodSymbol method, bool hasPreImage, boo return false; } - if (!IsImageParameter(parameters[paramIndex], Constants.PreImageTypeName, Constants.PreImageInterfaceName, entityType)) + if (!IsImageParameter(parameters[paramIndex], Constants.PreImageTypeName, Constants.PreImageInterfaceName, entityType, expectedNamespace)) { return false; } @@ -203,7 +204,7 @@ private static bool SignatureMatches(IMethodSymbol method, bool hasPreImage, boo return false; } - if (!IsImageParameter(parameters[paramIndex], Constants.PostImageTypeName, Constants.PostImageInterfaceName, entityType)) + if (!IsImageParameter(parameters[paramIndex], Constants.PostImageTypeName, Constants.PostImageInterfaceName, entityType, expectedNamespace)) { return false; } @@ -222,14 +223,17 @@ private static bool SignatureMatches(IMethodSymbol method, bool hasPreImage, boo /// For the generic interfaces, the type argument must match the registered entity type, otherwise the /// generated ActionWrapper would fail to compile when passing the concrete image. /// - private static bool IsImageParameter(IParameterSymbol parameter, string expectedWrapperType, string expectedInterfaceName, ITypeSymbol entityType) + private static bool IsImageParameter(IParameterSymbol parameter, string expectedWrapperType, string expectedInterfaceName, ITypeSymbol entityType, string expectedNamespace) { var type = parameter.Type; - // Concrete generated wrapper (PreImage / PostImage) - entity correctness is guaranteed by its namespace. + // Concrete generated wrapper (PreImage / PostImage). Match by namespace + name so a same-named + // type in a different namespace is not mistaken for the generated wrapper. Fall back to name-only + // when the expected namespace can't be resolved. if (type.Name == expectedWrapperType) { - return true; + return expectedNamespace == null + || type.ContainingNamespace?.ToDisplayString() == expectedNamespace; } // Shared image interfaces - must be declared in XrmPluginCore. diff --git a/XrmPluginCore.SourceGenerator/CodeGeneration/CustomApiClassGenerator.cs b/XrmPluginCore.SourceGenerator/CodeGeneration/CustomApiClassGenerator.cs index 39b50ab..8a9e8ce 100644 --- a/XrmPluginCore.SourceGenerator/CodeGeneration/CustomApiClassGenerator.cs +++ b/XrmPluginCore.SourceGenerator/CodeGeneration/CustomApiClassGenerator.cs @@ -59,7 +59,7 @@ private static void GenerateRequestClass(StringBuilder sb, CustomApiMetadata met foreach (var parameter in metadata.RequestParameters) { - sb.AppendLine($"{L2}public {EffectiveType(parameter, metadata.NullableAnnotationsEnabled)} {parameter.PropertyName} {{ get; set; }}"); + sb.AppendLine($"{L2}public {EffectiveType(parameter, metadata.NullableAnnotationsEnabled)} {EscapeIdentifier(parameter.PropertyName)} {{ get; set; }}"); } sb.AppendLine($"{L1}}}"); @@ -77,7 +77,7 @@ private static void GenerateResponseClass(StringBuilder sb, CustomApiMetadata me foreach (var property in metadata.ResponseProperties) { - sb.AppendLine($"{L2}public {EffectiveType(property, metadata.NullableAnnotationsEnabled)} {property.PropertyName} {{ get; set; }}"); + sb.AppendLine($"{L2}public {EffectiveType(property, metadata.NullableAnnotationsEnabled)} {EscapeIdentifier(property.PropertyName)} {{ get; set; }}"); } sb.AppendLine(); @@ -87,7 +87,9 @@ private static void GenerateResponseClass(StringBuilder sb, CustomApiMetadata me sb.AppendLine($"{L2}{{"); foreach (var property in metadata.ResponseProperties) { - sb.AppendLine($"{L3}{property.PropertyName} = {ToParameterName(property.PropertyName)};"); + // Qualify with 'this.' so the assignment is unambiguous even when the property name and the + // constructor parameter name resolve to the same identifier (e.g. a lowercase unique name). + sb.AppendLine($"{L3}this.{EscapeIdentifier(property.PropertyName)} = {ToParameterName(property.PropertyName)};"); } sb.AppendLine($"{L2}}}"); @@ -119,7 +121,7 @@ private static void GenerateActionWrapperClass(StringBuilder sb, CustomApiMetada { var castType = EffectiveType(parameter, metadata.NullableAnnotationsEnabled); sb.AppendLine( - $"{L5}{parameter.PropertyName} = context.InputParameters.Contains(\"{parameter.UniqueName}\") " + + $"{L5}{EscapeIdentifier(parameter.PropertyName)} = context.InputParameters.Contains(\"{parameter.UniqueName}\") " + $"? ({castType})context.InputParameters[\"{parameter.UniqueName}\"] : default,"); } sb.AppendLine($"{L4}}};"); @@ -134,7 +136,7 @@ private static void GenerateActionWrapperClass(StringBuilder sb, CustomApiMetada sb.AppendLine(); foreach (var property in metadata.ResponseProperties) { - sb.AppendLine($"{L4}context.OutputParameters[\"{property.UniqueName}\"] = response.{property.PropertyName};"); + sb.AppendLine($"{L4}context.OutputParameters[\"{property.UniqueName}\"] = response.{EscapeIdentifier(property.PropertyName)};"); } } else @@ -162,6 +164,14 @@ private static string EffectiveType(CustomApiParameterMetadata parameter, bool n return nullableEnabled ? parameter.ClrType + "?" : parameter.ClrType; } + /// + /// Escapes a reserved C# keyword with a verbatim @ so it is valid as an identifier. + /// returns None for non-keywords and for contextual keywords + /// (which are valid identifiers and need no escaping). + /// + private static string EscapeIdentifier(string identifier) + => SyntaxFacts.GetKeywordKind(identifier) != SyntaxKind.None ? "@" + identifier : identifier; + private static string ToParameterName(string propertyName) { if (string.IsNullOrEmpty(propertyName)) @@ -170,10 +180,7 @@ private static string ToParameterName(string propertyName) } var camelCase = char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1); - - // Escape reserved keywords with a verbatim @. SyntaxFacts.GetKeywordKind returns None for - // non-keywords and for contextual keywords (which are valid identifiers and need no escaping). - return SyntaxFacts.GetKeywordKind(camelCase) != SyntaxKind.None ? "@" + camelCase : camelCase; + return EscapeIdentifier(camelCase); } private static string GetFileHeader(bool nullableEnabled) => From 57577ad6c4783cf010b0f0a051091070cb2c8b15 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Tue, 30 Jun 2026 11:21:10 +0200 Subject: [PATCH 4/8] Add XPC3006: warn when typed Custom API name is not a compile-time constant The typed RegisterAPI(name, handlerMethodName) overload names its generated classes after the API, so a non-constant name produced no generated ActionWrapper and failed at runtime with no explanation. A dedicated analyzer now reports XPC3006 (Warning) at the name argument, pointing the developer to use nameof(...), a const, or a string literal. The action-based overloads are unaffected. Includes rule doc, tests, and changelog/README/CLAUDE entries. Co-Authored-By: Claude via Conducktor --- CLAUDE.md | 1 + README.md | 1 + .../CustomApiHandlerDiagnosticsTests.cs | 47 +++++++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + .../CustomApiNameNotConstantAnalyzer.cs | 57 +++++++++++++++++++ .../DiagnosticDescriptors.cs | 10 ++++ .../rules/XPC3006.md | 57 +++++++++++++++++++ XrmPluginCore/CHANGELOG.md | 1 + 8 files changed, 175 insertions(+) create mode 100644 XrmPluginCore.SourceGenerator/Analyzers/CustomApiNameNotConstantAnalyzer.cs create mode 100644 XrmPluginCore.SourceGenerator/rules/XPC3006.md diff --git a/CLAUDE.md b/CLAUDE.md index 7f22ab8..f866818 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -334,6 +334,7 @@ public class CallbackService | Rule | Severity | Meaning | | --- | --- | --- | +| XPC3006 | Warning | Custom API name must be a compile-time constant (`nameof`/`const`/literal) for generation | | XPC4004 | Error | Custom API handler method not found on the service type (code fix creates it) | | XPC4005 | Warning | Handler signature doesn't match the declared parameters, generated types don't exist yet | | XPC4006 | Error | Handler signature doesn't match, generated types exist (code fix corrects it) | diff --git a/README.md b/README.md index a8a9bc1..a3519a7 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,7 @@ The source generator includes analyzers that help catch common issues at compile | [XPC3002](XrmPluginCore.SourceGenerator/rules/XPC3002.md) | Info | Consider using modern image registration API | | [XPC3003](XrmPluginCore.SourceGenerator/rules/XPC3003.md) | Warning | Image registration without method reference | | [XPC3004](XrmPluginCore.SourceGenerator/rules/XPC3004.md) | Error | Do not use LocalPluginContext as TService in RegisterStep | +| [XPC3006](XrmPluginCore.SourceGenerator/rules/XPC3006.md) | Warning | Custom API name must be a compile-time constant | | [XPC4001](XrmPluginCore.SourceGenerator/rules/XPC4001.md) | Error | Handler method not found | | [XPC4002](XrmPluginCore.SourceGenerator/rules/XPC4002.md) | Warning | Handler signature does not match registered images | | [XPC4003](XrmPluginCore.SourceGenerator/rules/XPC4003.md) | Error | Handler signature does not match registered images | diff --git a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/CustomApiHandlerDiagnosticsTests.cs b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/CustomApiHandlerDiagnosticsTests.cs index 98b4d0f..865a03f 100644 --- a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/CustomApiHandlerDiagnosticsTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/CustomApiHandlerDiagnosticsTests.cs @@ -111,6 +111,53 @@ public class CallbackService diagnostics.Should().Contain(d => d.Id == "XPC4005" || d.Id == "XPC4006"); } + [Fact] + public async Task Should_Report_XPC3006_When_Api_Name_Is_Not_Constant() + { + const string source = """ + using XrmPluginCore; + using XrmPluginCore.Enums; + using Microsoft.Extensions.DependencyInjection; + + namespace TestNamespace + { + public class SomeApi : Plugin + { + public SomeApi() + { + var name = System.Guid.NewGuid().ToString(); + RegisterAPI(name, nameof(CallbackService.Handle)) + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + => services.AddScoped(); + } + + public class CallbackService + { + public object Handle() => null; + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source, new CustomApiNameNotConstantAnalyzer()); + + diagnostics.Should().ContainSingle(d => d.Id == "XPC3006") + .Which.Severity.Should().Be(DiagnosticSeverity.Warning); + } + + [Fact] + public async Task Should_Not_Report_XPC3006_When_Api_Name_Is_Nameof() + { + var source = WrapPlugin(RegistrationWithParams, serviceBody: "public SomeApiResponse Handle(SomeApiRequest request) => new SomeApiResponse(0);") + + GeneratedTypes; + + var diagnostics = await GetDiagnosticsAsync(source, new CustomApiNameNotConstantAnalyzer()); + + diagnostics.Should().NotContain(d => d.Id == "XPC3006"); + } + [Fact] public async Task Should_Report_XPC3001_For_String_Literal_Handler() { diff --git a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md index 502ee44..bf6865e 100644 --- a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md @@ -2,6 +2,7 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- +XPC3006 | XrmPluginCore.SourceGenerator | Warning | XPC3006 Custom API name must be a compile-time constant XPC4004 | XrmPluginCore.SourceGenerator | Error | XPC4004 Custom API handler method not found XPC4005 | XrmPluginCore.SourceGenerator | Warning | XPC4005 Custom API handler signature mismatch (generated types don't exist) XPC4006 | XrmPluginCore.SourceGenerator | Error | XPC4006 Custom API handler signature mismatch (generated types exist) diff --git a/XrmPluginCore.SourceGenerator/Analyzers/CustomApiNameNotConstantAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/CustomApiNameNotConstantAnalyzer.cs new file mode 100644 index 0000000..2b1e56b --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Analyzers/CustomApiNameNotConstantAnalyzer.cs @@ -0,0 +1,57 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; +using XrmPluginCore.SourceGenerator.Helpers; + +namespace XrmPluginCore.SourceGenerator.Analyzers; + +/// +/// Reports when a type-safe RegisterAPI<TService>(name, handlerMethodName) call is given a +/// name that is not a compile-time constant. The generated request/response classes and ActionWrapper +/// are named after the API, so a non-constant name silently produces no generated code and fails at +/// runtime (no ActionWrapper to discover). +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class CustomApiNameNotConstantAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticDescriptors.CustomApiNameNotConstant); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + } + + private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + + // Only the typed handler-name overload triggers generation; the action-based overloads do not + // require a constant name. + if (!RegisterApiHelper.IsRegisterApiCall(invocation, out _) || + !RegisterApiHelper.IsTypedHandlerOverload(invocation, context.SemanticModel)) + { + return; + } + + var arguments = invocation.ArgumentList.Arguments; + if (arguments.Count < 1) + { + return; + } + + // A resolvable constant name means generation can proceed. + if (RegisterApiHelper.GetApiName(invocation, context.SemanticModel) != null) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.CustomApiNameNotConstant, + arguments[0].Expression.GetLocation())); + } +} diff --git a/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs b/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs index d19e112..f3864f7 100644 --- a/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs +++ b/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs @@ -98,6 +98,16 @@ public static class DiagnosticDescriptors isEnabledByDefault: true, helpLinkUri: $"{HelpLinkBaseUri}/XPC4003.md"); + public static readonly DiagnosticDescriptor CustomApiNameNotConstant = new( + id: "XPC3006", + title: "Custom API name must be a compile-time constant", + messageFormat: "Custom API name must be a compile-time constant (use nameof(...) or a string literal) so the source generator can emit the request/response types and ActionWrapper", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "The typed RegisterAPI(name, handlerMethodName) overload requires a constant name so the generated classes can be named after the API. A non-constant name produces no generated ActionWrapper and fails at runtime.", + helpLinkUri: $"{HelpLinkBaseUri}/XPC3006.md"); + public static readonly DiagnosticDescriptor CustomApiHandlerMethodNotFound = new( id: "XPC4004", title: "Custom API handler method not found", diff --git a/XrmPluginCore.SourceGenerator/rules/XPC3006.md b/XrmPluginCore.SourceGenerator/rules/XPC3006.md new file mode 100644 index 0000000..321154e --- /dev/null +++ b/XrmPluginCore.SourceGenerator/rules/XPC3006.md @@ -0,0 +1,57 @@ +# XPC3006: Custom API name must be a compile-time constant + +## Severity + +Warning + +## Description + +The type-safe `RegisterAPI(name, handlerMethodName)` overload names its generated +`{ApiName}Request`/`{ApiName}Response` classes and `ActionWrapper` after the API name. The source +generator therefore needs the name to be a **compile-time constant** (a `nameof(...)`, a `const`, or +a string literal). + +If the name is a non-constant expression (a local variable, parameter, method call, etc.), the +generator cannot determine the class names, so **no `ActionWrapper` is generated**. The registration +then fails at runtime with an `InvalidPluginExecutionException` because no generated wrapper can be +discovered. + +## ❌ Example of violation + +```csharp +public class SomeCustomApi : Plugin +{ + public SomeCustomApi() + { + var name = ResolveName(); // not a compile-time constant + + // XPC3006: Custom API name must be a compile-time constant + RegisterAPI(name, nameof(CallbackService.Handle)) + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer); + } +} +``` + +## ✅ How to fix + +Use a `nameof(...)`, a `const`, or a string literal: + +```csharp +RegisterAPI(nameof(SomeCustomApi), nameof(CallbackService.Handle)) + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer); + +// or +RegisterAPI("contextand_SomeCustomApi", nameof(CallbackService.Handle)) + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer); +``` + +## Note + +This applies only to the typed `RegisterAPI(name, handlerMethodName)` overload. The +action-based overloads (`RegisterAPI(name, Action)` / +`RegisterCustomAPI(name, ...)`) do not generate code and accept any name expression. + +## See also + +- [XPC4004: Custom API handler method not found](XPC4004.md) +- [XPC4005: Custom API handler signature mismatch](XPC4005.md) diff --git a/XrmPluginCore/CHANGELOG.md b/XrmPluginCore/CHANGELOG.md index 08b919c..f66f218 100644 --- a/XrmPluginCore/CHANGELOG.md +++ b/XrmPluginCore/CHANGELOG.md @@ -3,6 +3,7 @@ * Add: Error XPC4004: Custom API handler method not found (with code fix to create the method). * Add: Warning XPC4005 / Error XPC4006: Custom API handler signature does not match the declared request parameters and response properties (with code fix to correct the signature). * Add: XPC3001 (Prefer `nameof` over string literal) now also covers the Custom API handler argument. +* Add: Warning XPC3006: the typed `RegisterAPI(name, handlerMethodName)` overload requires a compile-time constant name (so the generated classes can be named after the API); a non-constant name is reported instead of silently skipping generation. * Fix: Generated image properties now mirror the `[Obsolete]` attribute of the underlying entity property, so deprecation warnings (CS0612/CS0618) surface in the calling code instead of inside the auto-generated image class. * Fix: Generated code (images and Custom API request/response) now only emits nullable reference-type annotations (`string?`) and a `#nullable enable` directive when the consuming project has nullable reference types enabled. On projects without NRT (including .NET Framework / C# 7.3 defaults) the generated code is emitted without those annotations, keeping it compilable and warning-free. Nullable value types (`int?`) are always emitted. From 954daabf1f24b1f0ec381202dab8dc90f23af09e Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Tue, 30 Jun 2026 11:25:40 +0200 Subject: [PATCH 5/8] Validate typed RegisterAPI inputs and fail fast RegisterAPI(name, handlerMethodName) now throws ArgumentException when name or handlerMethodName is null/whitespace. Previously such a registration produced no action and no handler reference, so Execute() silently treated it as 'no registration' instead of failing, leading to a confusing runtime no-op. Adds a Theory covering each invalid combination. Co-Authored-By: Claude via Conducktor --- XrmPluginCore.Tests/CustomAPITests.cs | 16 ++++++++++++++++ .../TestCustomApis/TestCustomApis.cs | 11 +++++++++++ XrmPluginCore/CHANGELOG.md | 1 + XrmPluginCore/Plugin.cs | 13 +++++++++++++ 4 files changed, 41 insertions(+) diff --git a/XrmPluginCore.Tests/CustomAPITests.cs b/XrmPluginCore.Tests/CustomAPITests.cs index 0a1d3a8..1106898 100644 --- a/XrmPluginCore.Tests/CustomAPITests.cs +++ b/XrmPluginCore.Tests/CustomAPITests.cs @@ -95,6 +95,22 @@ public void RegisterCustomAPIMultipleRegistrationsShouldThrowInvalidOperationExc Assert.Throws(() => customApi.TryRegisterSecond()); } + [Theory] + [InlineData(null, "Handle")] + [InlineData("", "Handle")] + [InlineData(" ", "Handle")] + [InlineData("my_api", null)] + [InlineData("my_api", "")] + [InlineData("my_api", " ")] + public void RegisterTypedCustomAPIWithMissingNameOrHandlerShouldThrowArgumentException(string name, string handlerMethodName) + { + // Arrange + var customApi = new TestTypedCustomApiValidation(); + + // Act & Assert - misconfiguration is caught deterministically at registration time + Assert.Throws(() => customApi.Register(name, handlerMethodName)); + } + [Fact] public void GetRegistrationValidRegistrationShouldReturnConfiguration() { diff --git a/XrmPluginCore.Tests/TestCustomApis/TestCustomApis.cs b/XrmPluginCore.Tests/TestCustomApis/TestCustomApis.cs index 6237aaa..b342a52 100644 --- a/XrmPluginCore.Tests/TestCustomApis/TestCustomApis.cs +++ b/XrmPluginCore.Tests/TestCustomApis/TestCustomApis.cs @@ -88,6 +88,17 @@ private void Execute2(LocalPluginContext context) } } + // Helper custom API for testing input validation of the typed RegisterAPI overload. + public class TestTypedCustomApiValidation : Plugin + { + // Exposes the protected typed overload so tests can pass invalid inputs. The arguments are not + // compile-time constants here, which is irrelevant to the validation under test. +#pragma warning disable XPC3006 // Custom API name must be a compile-time constant + public void Register(string name, string handlerMethodName) + => RegisterAPI(name, handlerMethodName); +#pragma warning restore XPC3006 + } + // Helper custom API for testing service provider modification public class TestServiceProviderModificationCustomAPI : Plugin { diff --git a/XrmPluginCore/CHANGELOG.md b/XrmPluginCore/CHANGELOG.md index f66f218..9005c51 100644 --- a/XrmPluginCore/CHANGELOG.md +++ b/XrmPluginCore/CHANGELOG.md @@ -4,6 +4,7 @@ * Add: Warning XPC4005 / Error XPC4006: Custom API handler signature does not match the declared request parameters and response properties (with code fix to correct the signature). * Add: XPC3001 (Prefer `nameof` over string literal) now also covers the Custom API handler argument. * Add: Warning XPC3006: the typed `RegisterAPI(name, handlerMethodName)` overload requires a compile-time constant name (so the generated classes can be named after the API); a non-constant name is reported instead of silently skipping generation. +* Add: The typed `RegisterAPI(name, handlerMethodName)` overload now throws `ArgumentException` when the name or handler method name is null or whitespace, so misconfigurations fail fast at registration instead of being silently treated as "no registration" at execution time. * Fix: Generated image properties now mirror the `[Obsolete]` attribute of the underlying entity property, so deprecation warnings (CS0612/CS0618) surface in the calling code instead of inside the auto-generated image class. * Fix: Generated code (images and Custom API request/response) now only emits nullable reference-type annotations (`string?`) and a `#nullable enable` directive when the consuming project has nullable reference types enabled. On projects without NRT (including .NET Framework / C# 7.3 defaults) the generated code is emitted without those annotations, keeping it compilable and warning-free. Nullable value types (`int?`) are always emitted. diff --git a/XrmPluginCore/Plugin.cs b/XrmPluginCore/Plugin.cs index 4569394..d66b36e 100644 --- a/XrmPluginCore/Plugin.cs +++ b/XrmPluginCore/Plugin.cs @@ -400,9 +400,22 @@ protected CustomApiConfigBuilder RegisterAPI(string name, ActionThe service type that contains the handler method /// The unique name of the Custom API /// The name of the handler method (use nameof(TService.MethodName)) + /// If or is null or whitespace /// If called multiple times in the same class protected CustomApiConfigBuilder RegisterAPI(string name, string handlerMethodName) { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("A Custom API name must be provided.", nameof(name)); + } + + if (string.IsNullOrWhiteSpace(handlerMethodName)) + { + throw new ArgumentException( + "A handler method name must be provided. Use nameof(TService.MethodName) for compile-time safety.", + nameof(handlerMethodName)); + } + if (RegisteredCustomApi != null) { throw new InvalidOperationException("You cannot register multiple CustomAPIs in the same class"); From a23e630e0ab9e4713a4ed02ac0a05ded37cf7001 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Tue, 30 Jun 2026 12:06:49 +0200 Subject: [PATCH 6/8] Fix wrapper discovery for plugins in the global namespace Runtime wrapper-type discovery used GetType().Namespace, which is null for plugin classes in the global namespace, producing a leading-dot type name that never matched the generated types. The source generator places such types under the literal "GlobalNamespace". The runtime now mirrors that fallback (Plugin.ChildClassNamespace) for both plugin-step and Custom API wrapper discovery. Adds an end-to-end test with a global-namespace Custom API plugin and a cross-reference comment in the generator. Co-Authored-By: Claude via Conducktor --- .../Parsers/RegistrationParser.cs | 2 + .../GlobalNamespaceCustomApiTests.cs | 57 +++++++++++++++++++ XrmPluginCore/Plugin.cs | 11 +++- 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 XrmPluginCore.Tests/GlobalNamespaceCustomApiTests.cs diff --git a/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs b/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs index ed83b66..5aaedf7 100644 --- a/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs +++ b/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs @@ -432,6 +432,8 @@ public static string GetNamespace(this SyntaxNode node) node = node.Parent; } + // No namespace declaration: emit generated types under a literal "GlobalNamespace". The runtime + // mirrors this fallback for wrapper discovery (Plugin.GlobalNamespaceFallback) - keep them in sync. if (namespaces.Count == 0) return "GlobalNamespace"; diff --git a/XrmPluginCore.Tests/GlobalNamespaceCustomApiTests.cs b/XrmPluginCore.Tests/GlobalNamespaceCustomApiTests.cs new file mode 100644 index 0000000..17c1fb3 --- /dev/null +++ b/XrmPluginCore.Tests/GlobalNamespaceCustomApiTests.cs @@ -0,0 +1,57 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Xrm.Sdk; +using XrmPluginCore; +using XrmPluginCore.Enums; +using XrmPluginCore.Tests.Helpers; +using Xunit; + +// The generated Request/Response/ActionWrapper for a plugin declared in the GLOBAL namespace are +// emitted under the literal "GlobalNamespace" namespace by the source generator. +using GlobalNamespace; + +// Intentionally declared in the GLOBAL namespace (no namespace declaration) to exercise the +// GlobalNamespace fallback in runtime wrapper discovery: Type.Namespace is null here, and the runtime +// must mirror the generator's "GlobalNamespace" fallback to discover the generated ActionWrapper. +public class GlobalNsCustomApi : Plugin +{ + public GlobalNsCustomApi() + { + RegisterAPI(nameof(GlobalNsCustomApi), nameof(GlobalNsCustomApiService.Handle)) + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + services.AddScoped(); + return base.OnBeforeBuildServiceProvider(services); + } +} + +public class GlobalNsCustomApiService +{ + public GlobalNsCustomApiResponse Handle() => new GlobalNsCustomApiResponse(200); +} + +namespace XrmPluginCore.Tests +{ + public class GlobalNamespaceCustomApiTests + { + [Fact] + public void Execute_ShouldDiscoverGeneratedWrapper_ForGlobalNamespacePlugin() + { + // Arrange + var customApi = new GlobalNsCustomApi(); + var mockProvider = new MockServiceProvider(); + var outputParameters = new ParameterCollection(); + mockProvider.SetupOutputParameters(outputParameters); + + // Act - before the GlobalNamespace fallback this threw (no ActionWrapper discovered) + customApi.Execute(mockProvider.ServiceProvider); + + // Assert + outputParameters.Should().ContainKey("StatusCode"); + outputParameters["StatusCode"].Should().Be(200); + } + } +} diff --git a/XrmPluginCore/Plugin.cs b/XrmPluginCore/Plugin.cs index d66b36e..83bf7b6 100644 --- a/XrmPluginCore/Plugin.cs +++ b/XrmPluginCore/Plugin.cs @@ -22,14 +22,21 @@ public abstract class Plugin : IPlugin, IPluginDefinition, ICustomApiDefinition { private string ChildClassName { get; } private string ChildClassShortName { get; } + private string ChildClassNamespace { get; } private List RegisteredPluginSteps { get; } = []; private CustomApiRegistration RegisteredCustomApi { get; set; } + // The source generator places generated types under "GlobalNamespace" when the plugin class has no + // namespace (see SyntaxExtensions.GetNamespace in the generator). Type.Namespace is null in that case, + // so mirror the same fallback here for wrapper-type discovery to match. Keep the literal in sync. + private const string GlobalNamespaceFallback = "GlobalNamespace"; + protected Plugin() { var type = GetType(); ChildClassName = type.ToString(); ChildClassShortName = type.Name; + ChildClassNamespace = string.IsNullOrEmpty(type.Namespace) ? GlobalNamespaceFallback : type.Namespace; } /// @@ -328,7 +335,7 @@ protected PluginStepConfigBuilder RegisterStep( // Compute the generated ActionWrapper type name up front. Must match the namespace the source // generator emits: {Namespace}.PluginRegistrations.{PluginClassName}.{Entity}{Operation}{Stage}. var wrapperTypeName = - $"{GetType().Namespace}.PluginRegistrations.{ChildClassShortName}." + + $"{ChildClassNamespace}.PluginRegistrations.{ChildClassShortName}." + $"{typeof(TEntity).Name}{eventOperation}{executionStage}.ActionWrapper"; var registration = new PluginStepRegistration( @@ -464,7 +471,7 @@ private PluginStepRegistration GetMatchingRegistration(IPluginExecutionContext c if (RegisteredCustomApi.Action == null && RegisteredCustomApi.HandlerMethodName != null) { handlerMethodName = RegisteredCustomApi.HandlerMethodName; - wrapperTypeName = $"{GetType().Namespace}.{IdentifierSanitizer.Sanitize(RegisteredCustomApi.ConfigBuilder.Name)}ActionWrapper"; + wrapperTypeName = $"{ChildClassNamespace}.{IdentifierSanitizer.Sanitize(RegisteredCustomApi.ConfigBuilder.Name)}ActionWrapper"; } return new PluginStepRegistration( From 1ba949a20011b4a040ddd6d44200de9b30738f81 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Tue, 30 Jun 2026 13:04:09 +0200 Subject: [PATCH 7/8] Preserve leading digit when sanitizing identifiers Both IdentifierSanitizer (runtime) and IdentifierHelper (generator) replaced a leading digit with '_' in the loop AND then prepended another '_', dropping the digit and collapsing names that differ only by their leading digit (e.g. "1Foo"/"2Foo" both became "__Foo"). Digits are now kept in the loop and only the leading-digit prefix is added, so "1Foo" -> "_1Foo". The two implementations remain identical (runtime discovery must match generator emission). Adds a generation test and an end-to-end test with a digit-starting API name. Co-Authored-By: Claude via Conducktor --- .../CustomApiClassGenerationTests.cs | 41 +++++++++++++++++++ .../Helpers/IdentifierHelper.cs | 22 ++++------ .../TypeSafe/DigitNamedCustomApi.cs | 29 +++++++++++++ XrmPluginCore.Tests/TypeSafeCustomApiTests.cs | 16 ++++++++ XrmPluginCore/Helpers/IdentifierSanitizer.cs | 22 ++++------ 5 files changed, 100 insertions(+), 30 deletions(-) create mode 100644 XrmPluginCore.Tests/TestCustomApis/TypeSafe/DigitNamedCustomApi.cs diff --git a/XrmPluginCore.SourceGenerator.Tests/GenerationTests/CustomApiClassGenerationTests.cs b/XrmPluginCore.SourceGenerator.Tests/GenerationTests/CustomApiClassGenerationTests.cs index beb261a..65e7b4a 100644 --- a/XrmPluginCore.SourceGenerator.Tests/GenerationTests/CustomApiClassGenerationTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/GenerationTests/CustomApiClassGenerationTests.cs @@ -208,6 +208,47 @@ public void Should_Not_Annotate_Reference_Types_When_Nullable_Disabled() generated.Should().Contain("public int? Count { get; set; }"); } + [Fact] + public void Should_Preserve_Leading_Digit_In_Sanitized_Class_Names() + { + // An API name that starts with a digit is not a valid identifier. The generator prefixes a single + // '_' rather than dropping the digit, so "1CustomApi" -> "_1CustomApi" (not "__CustomApi"), which + // also keeps names that differ only by their leading digit distinct. + const string source = """ + using XrmPluginCore; + using XrmPluginCore.Enums; + using Microsoft.Extensions.DependencyInjection; + + namespace TestNamespace + { + public class SomeApi : Plugin + { + public SomeApi() + { + RegisterAPI("1CustomApi", nameof(CallbackService.Handle)) + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + => services.AddScoped(); + } + + public class CallbackService + { + public _1CustomApiResponse Handle() => new _1CustomApiResponse(0); + } + } + """; + + var result = GeneratorTestHelper.RunCustomApiGenerator(CompilationHelper.CreateCompilation(source)); + var generated = result.GeneratedTrees[0].GetText().ToString(); + + generated.Should().Contain("public sealed class _1CustomApiResponse"); + generated.Should().Contain("internal sealed class _1CustomApiActionWrapper"); + // The digit is preserved, not collapsed into a second underscore + generated.Should().NotContain("__CustomApi"); + } + [Fact] public void Should_Not_Generate_For_Action_Based_RegisterAPI() { diff --git a/XrmPluginCore.SourceGenerator/Helpers/IdentifierHelper.cs b/XrmPluginCore.SourceGenerator/Helpers/IdentifierHelper.cs index 4503cf2..3eeb4cc 100644 --- a/XrmPluginCore.SourceGenerator/Helpers/IdentifierHelper.cs +++ b/XrmPluginCore.SourceGenerator/Helpers/IdentifierHelper.cs @@ -19,24 +19,16 @@ public static string Sanitize(string value) return "_"; } - var sb = new StringBuilder(value.Length); - for (var i = 0; i < value.Length; i++) + var sb = new StringBuilder(value.Length + 1); + foreach (var c in value) { - var c = value[i]; - var isLetterOrUnderscore = char.IsLetter(c) || c == '_'; - var isDigit = char.IsDigit(c); - - if (isLetterOrUnderscore || (isDigit && i > 0)) - { - sb.Append(c); - } - else - { - sb.Append('_'); - } + // Keep letters, digits and underscores; replace anything else. Digits are kept here (even + // at the start) so they are preserved rather than collapsed - the leading-digit case is + // handled by the prefix below. + sb.Append(char.IsLetterOrDigit(c) || c == '_' ? c : '_'); } - // An identifier cannot start with a digit + // An identifier cannot start with a digit; prefix with '_' so the original digit is preserved. if (char.IsDigit(value[0])) { sb.Insert(0, '_'); diff --git a/XrmPluginCore.Tests/TestCustomApis/TypeSafe/DigitNamedCustomApi.cs b/XrmPluginCore.Tests/TestCustomApis/TypeSafe/DigitNamedCustomApi.cs new file mode 100644 index 0000000..fcd2ccf --- /dev/null +++ b/XrmPluginCore.Tests/TestCustomApis/TypeSafe/DigitNamedCustomApi.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.DependencyInjection; +using XrmPluginCore.Enums; + +namespace XrmPluginCore.Tests.TestCustomApis.TypeSafe; + +/// +/// Custom API whose name starts with a digit ("1DigitApi"). Exercises the identifier sanitizer end to +/// end: the runtime (wrapper discovery) and the generator (class emission) must produce the same +/// "_1DigitApi" prefix, otherwise the generated ActionWrapper is never discovered. +/// +public class DigitNamedCustomApi : Plugin +{ + public DigitNamedCustomApi() + { + RegisterAPI("1DigitApi", nameof(DigitNamedCustomApiService.Handle)) + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + { + services.AddScoped(); + return base.OnBeforeBuildServiceProvider(services); + } +} + +public class DigitNamedCustomApiService +{ + public _1DigitApiResponse Handle() => new _1DigitApiResponse(200); +} diff --git a/XrmPluginCore.Tests/TypeSafeCustomApiTests.cs b/XrmPluginCore.Tests/TypeSafeCustomApiTests.cs index 3cb2de2..16e16bf 100644 --- a/XrmPluginCore.Tests/TypeSafeCustomApiTests.cs +++ b/XrmPluginCore.Tests/TypeSafeCustomApiTests.cs @@ -70,6 +70,22 @@ public void Execute_ShouldUseDefault_WhenOptionalParameterMissing() outputParameters["ErrorMessage"].Should().Be($"contact:{entityId}"); } + [Fact] + public void Execute_ShouldDiscoverWrapper_ForDigitStartingApiName() + { + // The API name "1DigitApi" sanitizes to "_1DigitApi". This passes only if the runtime's + // discovery sanitizer and the generator's emission sanitizer agree on the result. + var customApi = new DigitNamedCustomApi(); + var mockProvider = new MockServiceProvider(); + var outputParameters = new ParameterCollection(); + mockProvider.SetupOutputParameters(outputParameters); + + customApi.Execute(mockProvider.ServiceProvider); + + outputParameters.Should().ContainKey("StatusCode"); + outputParameters["StatusCode"].Should().Be(200); + } + [Fact] public void Registration_ShouldContainDeclaredParameters() { diff --git a/XrmPluginCore/Helpers/IdentifierSanitizer.cs b/XrmPluginCore/Helpers/IdentifierSanitizer.cs index 2936e8d..84ffeb9 100644 --- a/XrmPluginCore/Helpers/IdentifierSanitizer.cs +++ b/XrmPluginCore/Helpers/IdentifierSanitizer.cs @@ -19,24 +19,16 @@ public static string Sanitize(string value) return "_"; } - var sb = new StringBuilder(value.Length); - for (var i = 0; i < value.Length; i++) + var sb = new StringBuilder(value.Length + 1); + foreach (var c in value) { - var c = value[i]; - var isLetterOrUnderscore = char.IsLetter(c) || c == '_'; - var isDigit = char.IsDigit(c); - - if (isLetterOrUnderscore || (isDigit && i > 0)) - { - sb.Append(c); - } - else - { - sb.Append('_'); - } + // Keep letters, digits and underscores; replace anything else. Digits are kept here (even + // at the start) so they are preserved rather than collapsed - the leading-digit case is + // handled by the prefix below. + sb.Append(char.IsLetterOrDigit(c) || c == '_' ? c : '_'); } - // An identifier cannot start with a digit + // An identifier cannot start with a digit; prefix with '_' so the original digit is preserved. if (char.IsDigit(value[0])) { sb.Insert(0, '_'); From 83fd2bdb996764b144298d2d57e730411e8325e2 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Tue, 30 Jun 2026 14:45:49 +0200 Subject: [PATCH 8/8] Resolve registration arguments by name and marshal inputs via TryGetValue Analyzers and parsers resolved the Custom API name, handler and parameter unique names (and the plugin step's event/stage/handler) by ordinal position. Named arguments may be supplied in any legal order, so positional indexing could resolve the wrong value or skip generation. Introduce a shared ArgumentBinder that maps each argument expression to its parameter symbol (honoring named and positional forms) and route RegisterAPI/RegisterStep argument lookups through it. Generate request marshalling with the strongly-typed ParameterCollection.TryGetValue overload instead of Contains(...) ? (T)[...] : default, matching the pattern used elsewhere. The optional value-type case uses the non-nullable underlying type as the generic argument so the boxed input value matches. Co-Authored-By: Claude via Conducktor --- .../CustomApiClassGenerationTests.cs | 63 ++++++++++++++- .../CustomApiHandlerMethodNotFoundAnalyzer.cs | 7 +- ...stomApiHandlerSignatureMismatchAnalyzer.cs | 7 +- .../CustomApiNameNotConstantAnalyzer.cs | 6 +- .../Analyzers/PreferNameofAnalyzer.cs | 15 ++-- .../CodeGeneration/CustomApiClassGenerator.cs | 27 +++++-- XrmPluginCore.SourceGenerator/Constants.cs | 11 +++ .../Helpers/ArgumentBinder.cs | 76 +++++++++++++++++++ .../Helpers/RegisterApiHelper.cs | 32 +++++--- .../Helpers/RegisterStepHelper.cs | 26 ++++++- .../Parsers/CustomApiRegistrationParser.cs | 52 ++----------- .../Parsers/RegistrationParser.cs | 24 ++++-- 12 files changed, 251 insertions(+), 95 deletions(-) create mode 100644 XrmPluginCore.SourceGenerator/Helpers/ArgumentBinder.cs diff --git a/XrmPluginCore.SourceGenerator.Tests/GenerationTests/CustomApiClassGenerationTests.cs b/XrmPluginCore.SourceGenerator.Tests/GenerationTests/CustomApiClassGenerationTests.cs index 65e7b4a..6e45ff0 100644 --- a/XrmPluginCore.SourceGenerator.Tests/GenerationTests/CustomApiClassGenerationTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/GenerationTests/CustomApiClassGenerationTests.cs @@ -54,8 +54,12 @@ public void Should_Generate_ActionWrapper_Marshalling_Inputs_And_Outputs() generated.Should().Contain("internal sealed class SomeApiActionWrapper : IActionWrapper"); generated.Should().Contain("var service = serviceProvider.GetRequiredService();"); - generated.Should().Contain("var request = new SomeApiRequest"); - generated.Should().Contain("EntityLogicalName = context.InputParameters.Contains(\"EntityLogicalName\") ? (string?)context.InputParameters[\"EntityLogicalName\"] : default,"); + generated.Should().Contain("var request = new SomeApiRequest();"); + // InputParameters are read via the strongly-typed ParameterCollection.TryGetValue overload + generated.Should().Contain("if (context.InputParameters.TryGetValue(\"EntityLogicalName\", out var entityLogicalName))"); + generated.Should().Contain("request.EntityLogicalName = entityLogicalName;"); + // Optional value-type parameter uses the non-nullable underlying type as the generic argument + generated.Should().Contain("if (context.InputParameters.TryGetValue(\"Count\", out var count))"); generated.Should().Contain("var response = service.Handle(request);"); generated.Should().Contain("context.OutputParameters[\"StatusCode\"] = response.StatusCode;"); generated.Should().Contain("context.OutputParameters[\"ErrorMessage\"] = response.ErrorMessage;"); @@ -171,8 +175,10 @@ public class CallbackService // Property declarations generated.Should().Contain("public string? @namespace { get; set; }"); generated.Should().Contain("public string? @event { get; set; }"); - // Request object initializer (dictionary key stays the raw unique name) - generated.Should().Contain("@namespace = context.InputParameters.Contains(\"namespace\")"); + // Request marshalling (dictionary key stays the raw unique name; the escaped identifier is used for + // both the out variable and the property assignment) + generated.Should().Contain("if (context.InputParameters.TryGetValue(\"namespace\", out var @namespace))"); + generated.Should().Contain("request.@namespace = @namespace;"); // Response constructor assignment is this-qualified so it sets the property, not the parameter generated.Should().Contain("this.@event = @event;"); // Response member access in the wrapper @@ -249,6 +255,55 @@ public class CallbackService generated.Should().NotContain("__CustomApi"); } + [Fact] + public void Should_Resolve_Named_Arguments_In_Any_Order() + { + // Named arguments may legally be supplied in any order. The generator must resolve the API name, + // handler and each parameter's unique name by parameter name - not by ordinal position - otherwise + // it would pick the wrong values (or skip generation entirely). + const string source = """ + using XrmPluginCore; + using XrmPluginCore.Enums; + using Microsoft.Extensions.DependencyInjection; + + namespace TestNamespace + { + public class SomeApi : Plugin + { + public SomeApi() + { + RegisterAPI(handlerMethodName: nameof(CallbackService.Handle), name: nameof(SomeApi)) + .AddRequestParameter(type: CustomApiParameterType.Guid, uniqueName: "EntityId") + .AddResponseProperty(type: CustomApiParameterType.Integer, uniqueName: "StatusCode"); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + => services.AddScoped(); + } + + public class CallbackService + { + public SomeApiResponse Handle(SomeApiRequest request) => new SomeApiResponse(0); + } + } + """; + + var result = GeneratorTestHelper.RunCustomApiGenerator(CompilationHelper.CreateCompilation(source)); + + result.GeneratedTrees.Should().NotBeEmpty(); + var generated = result.GeneratedTrees[0].GetText().ToString(); + + // API name resolved from the named 'name' argument (the second one at the call site) + generated.Should().Contain("public sealed class SomeApiRequest"); + generated.Should().Contain("public sealed class SomeApiResponse"); + generated.Should().Contain("internal sealed class SomeApiActionWrapper"); + // Parameter unique names resolved from the named 'uniqueName' argument (the second one) + generated.Should().Contain("public System.Guid EntityId { get; set; }"); + generated.Should().Contain("public int StatusCode { get; set; }"); + // Handler resolved from the named 'handlerMethodName' argument (the first one) + generated.Should().Contain("var response = service.Handle(request);"); + } + [Fact] public void Should_Not_Generate_For_Action_Based_RegisterAPI() { diff --git a/XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerMethodNotFoundAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerMethodNotFoundAnalyzer.cs index a0af67e..c9e7bce 100644 --- a/XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerMethodNotFoundAnalyzer.cs +++ b/XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerMethodNotFoundAnalyzer.cs @@ -35,14 +35,13 @@ private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) return; } - var arguments = invocation.ArgumentList.Arguments; - if (arguments.Count < 2) + var handlerArgument = RegisterApiHelper.GetHandlerArgument(invocation, context.SemanticModel); + if (handlerArgument == null) { return; } - var handlerArgument = arguments[1].Expression; - var methodName = RegisterApiHelper.GetHandlerMethodName(invocation); + var methodName = RegisterApiHelper.GetHandlerMethodName(invocation, context.SemanticModel); if (methodName == null) { return; diff --git a/XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerSignatureMismatchAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerSignatureMismatchAnalyzer.cs index 84c1026..6f14bbd 100644 --- a/XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerSignatureMismatchAnalyzer.cs +++ b/XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerSignatureMismatchAnalyzer.cs @@ -38,14 +38,13 @@ private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) return; } - var arguments = invocation.ArgumentList.Arguments; - if (arguments.Count < 2) + var handlerArgument = RegisterApiHelper.GetHandlerArgument(invocation, context.SemanticModel); + if (handlerArgument == null) { return; } - var handlerArgument = arguments[1].Expression; - var methodName = RegisterApiHelper.GetHandlerMethodName(invocation); + var methodName = RegisterApiHelper.GetHandlerMethodName(invocation, context.SemanticModel); if (methodName == null) { return; diff --git a/XrmPluginCore.SourceGenerator/Analyzers/CustomApiNameNotConstantAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/CustomApiNameNotConstantAnalyzer.cs index 2b1e56b..fc53425 100644 --- a/XrmPluginCore.SourceGenerator/Analyzers/CustomApiNameNotConstantAnalyzer.cs +++ b/XrmPluginCore.SourceGenerator/Analyzers/CustomApiNameNotConstantAnalyzer.cs @@ -38,8 +38,8 @@ private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) return; } - var arguments = invocation.ArgumentList.Arguments; - if (arguments.Count < 1) + var nameArgument = RegisterApiHelper.GetNameArgument(invocation, context.SemanticModel); + if (nameArgument == null) { return; } @@ -52,6 +52,6 @@ private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) context.ReportDiagnostic(Diagnostic.Create( DiagnosticDescriptors.CustomApiNameNotConstant, - arguments[0].Expression.GetLocation())); + nameArgument.GetLocation())); } } diff --git a/XrmPluginCore.SourceGenerator/Analyzers/PreferNameofAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/PreferNameofAnalyzer.cs index 549af43..f8f96d5 100644 --- a/XrmPluginCore.SourceGenerator/Analyzers/PreferNameofAnalyzer.cs +++ b/XrmPluginCore.SourceGenerator/Analyzers/PreferNameofAnalyzer.cs @@ -76,14 +76,17 @@ private static ExpressionSyntax GetHandlerArgument( return null; } - var arguments = invocation.ArgumentList.Arguments; - if (arguments.Count < 3) + // Resolve by parameter name so named/reordered arguments are honored. Only the typed + // handler-name overload has a 'handlerMethodName' parameter; the action overloads don't, so + // this returns null for them (their lambda argument is never a string literal anyway). + var handler = ArgumentBinder.GetArgument(invocation, semanticModel, Constants.ParameterHandlerMethodName); + if (handler == null) { return null; } serviceType = stepGeneric.TypeArgumentList.Arguments[1].ToString(); - return arguments[2].Expression; + return handler; } if (RegisterApiHelper.IsRegisterApiCall(invocation, out var apiGeneric) && @@ -94,14 +97,14 @@ private static ExpressionSyntax GetHandlerArgument( return null; } - var arguments = invocation.ArgumentList.Arguments; - if (arguments.Count < 2) + var handler = RegisterApiHelper.GetHandlerArgument(invocation, semanticModel); + if (handler == null) { return null; } serviceType = apiGeneric.TypeArgumentList.Arguments[0].ToString(); - return arguments[1].Expression; + return handler; } return null; diff --git a/XrmPluginCore.SourceGenerator/CodeGeneration/CustomApiClassGenerator.cs b/XrmPluginCore.SourceGenerator/CodeGeneration/CustomApiClassGenerator.cs index 8a9e8ce..0af0e80 100644 --- a/XrmPluginCore.SourceGenerator/CodeGeneration/CustomApiClassGenerator.cs +++ b/XrmPluginCore.SourceGenerator/CodeGeneration/CustomApiClassGenerator.cs @@ -115,16 +115,18 @@ private static void GenerateActionWrapperClass(StringBuilder sb, CustomApiMetada if (metadata.HasRequest) { sb.AppendLine(); - sb.AppendLine($"{L4}var request = new {metadata.RequestClassName}"); - sb.AppendLine($"{L4}{{"); + sb.AppendLine($"{L4}var request = new {metadata.RequestClassName}();"); foreach (var parameter in metadata.RequestParameters) { - var castType = EffectiveType(parameter, metadata.NullableAnnotationsEnabled); - sb.AppendLine( - $"{L5}{EscapeIdentifier(parameter.PropertyName)} = context.InputParameters.Contains(\"{parameter.UniqueName}\") " + - $"? ({castType})context.InputParameters[\"{parameter.UniqueName}\"] : default,"); + // ParameterCollection.TryGetValue performs the type check and cast in one step; the + // property keeps its default when the input parameter is absent (e.g. optional parameters). + var marshalType = MarshalType(parameter); + var local = ToParameterName(parameter.PropertyName); + sb.AppendLine($"{L4}if (context.InputParameters.TryGetValue<{marshalType}>(\"{parameter.UniqueName}\", out var {local}))"); + sb.AppendLine($"{L4}{{"); + sb.AppendLine($"{L5}request.{EscapeIdentifier(parameter.PropertyName)} = {local};"); + sb.AppendLine($"{L4}}}"); } - sb.AppendLine($"{L4}}};"); } sb.AppendLine(); @@ -172,6 +174,17 @@ private static string EffectiveType(CustomApiParameterMetadata parameter, bool n private static string EscapeIdentifier(string identifier) => SyntaxFacts.GetKeywordKind(identifier) != SyntaxKind.None ? "@" + identifier : identifier; + /// + /// The generic argument to pass to ParameterCollection.TryGetValue<T>. The value stored in + /// InputParameters is boxed as the non-nullable underlying type for value types (and the bare reference + /// type otherwise), so the trailing nullable ? of an optional value type is stripped here. + /// + private static string MarshalType(CustomApiParameterMetadata parameter) + { + var clrType = parameter.ClrType; + return clrType.EndsWith("?") ? clrType.Substring(0, clrType.Length - 1) : clrType; + } + private static string ToParameterName(string propertyName) { if (string.IsNullOrEmpty(propertyName)) diff --git a/XrmPluginCore.SourceGenerator/Constants.cs b/XrmPluginCore.SourceGenerator/Constants.cs index 029b745..4f59878 100644 --- a/XrmPluginCore.SourceGenerator/Constants.cs +++ b/XrmPluginCore.SourceGenerator/Constants.cs @@ -21,6 +21,17 @@ internal static class Constants public const string AddRequestParameterMethodName = "AddRequestParameter"; public const string AddResponsePropertyMethodName = "AddResponseProperty"; + // RegisterStep / RegisterAPI parameter names. Arguments are resolved by parameter name (not ordinal + // position) so callers may legally use named arguments in any order. + public const string ParameterName = "name"; + public const string ParameterHandlerMethodName = "handlerMethodName"; + public const string ParameterAction = "action"; + public const string ParameterEventOperation = "eventOperation"; + public const string ParameterExecutionStage = "executionStage"; + public const string ParameterUniqueName = "uniqueName"; + public const string ParameterType = "type"; + public const string ParameterIsOptional = "isOptional"; + // Custom API generated class name suffixes (combined with the sanitized API name) public const string RequestClassSuffix = "Request"; public const string ResponseClassSuffix = "Response"; diff --git a/XrmPluginCore.SourceGenerator/Helpers/ArgumentBinder.cs b/XrmPluginCore.SourceGenerator/Helpers/ArgumentBinder.cs new file mode 100644 index 0000000..2a02a75 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Helpers/ArgumentBinder.cs @@ -0,0 +1,76 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Generic; +using System.Linq; + +namespace XrmPluginCore.SourceGenerator.Helpers; + +/// +/// Binds invocation argument expressions to their parameter names, honoring both positional and named +/// arguments. Callers can legally reorder arguments with name: syntax (e.g. +/// RegisterAPI<T>(handlerMethodName: ..., name: ...)), so analyzers and parsers must resolve +/// arguments by parameter name rather than by ordinal position. +/// +internal static class ArgumentBinder +{ + /// + /// Returns a map of parameter name to the argument expression supplied for it. Parameters with no + /// supplied argument (defaults omitted at the call site) are absent from the map. + /// + public static IReadOnlyDictionary Bind( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel) + { + var result = new Dictionary(); + + var method = ResolveMethod(invocation, semanticModel); + if (method == null) + { + return result; + } + + var arguments = invocation.ArgumentList.Arguments; + for (var i = 0; i < arguments.Count; i++) + { + var argument = arguments[i]; + + // A named argument maps by its explicit name; a positional argument at list index i maps to + // parameter i (the language requires positional arguments to stay in their natural position, + // so the index is always correct for compilable code). + IParameterSymbol parameter; + if (argument.NameColon != null) + { + var name = argument.NameColon.Name.Identifier.Text; + parameter = method.Parameters.FirstOrDefault(p => p.Name == name); + } + else + { + parameter = i < method.Parameters.Length ? method.Parameters[i] : null; + } + + if (parameter != null) + { + result[parameter.Name] = argument.Expression; + } + } + + return result; + } + + /// + /// Returns the argument expression bound to , or null when the + /// parameter is not supplied or the call cannot be resolved. + /// + public static ExpressionSyntax GetArgument( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel, + string parameterName) + => Bind(invocation, semanticModel).TryGetValue(parameterName, out var expression) ? expression : null; + + private static IMethodSymbol ResolveMethod(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + { + var symbolInfo = semanticModel.GetSymbolInfo(invocation); + return symbolInfo.Symbol as IMethodSymbol + ?? symbolInfo.CandidateSymbols.OfType().FirstOrDefault(); + } +} diff --git a/XrmPluginCore.SourceGenerator/Helpers/RegisterApiHelper.cs b/XrmPluginCore.SourceGenerator/Helpers/RegisterApiHelper.cs index d666e72..4dae775 100644 --- a/XrmPluginCore.SourceGenerator/Helpers/RegisterApiHelper.cs +++ b/XrmPluginCore.SourceGenerator/Helpers/RegisterApiHelper.cs @@ -53,28 +53,42 @@ public static bool IsTypedHandlerOverload(InvocationExpressionSyntax invocation, } /// - /// Resolves the API name (the first argument) as a compile-time constant string. Returns null when - /// it is not a constant. + /// Returns the name argument expression, resolved by parameter name (so named/reordered + /// arguments are honored). Returns null when absent. + /// + public static ExpressionSyntax GetNameArgument(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + => ArgumentBinder.GetArgument(invocation, semanticModel, Constants.ParameterName); + + /// + /// Returns the handlerMethodName argument expression, resolved by parameter name. Returns null + /// when absent. + /// + public static ExpressionSyntax GetHandlerArgument(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + => ArgumentBinder.GetArgument(invocation, semanticModel, Constants.ParameterHandlerMethodName); + + /// + /// Resolves the name argument as a compile-time constant string. Returns null when it is not a + /// constant. /// public static string GetApiName(InvocationExpressionSyntax invocation, SemanticModel semanticModel) { - var arguments = invocation.ArgumentList.Arguments; - if (arguments.Count < 1) + var nameArgument = GetNameArgument(invocation, semanticModel); + if (nameArgument == null) { return null; } - var constant = semanticModel.GetConstantValue(arguments[0].Expression); + var constant = semanticModel.GetConstantValue(nameArgument); return constant.HasValue ? constant.Value as string : null; } /// - /// Extracts the handler method name (second argument) from nameof()/string literal. + /// Extracts the handler method name from the handlerMethodName argument (nameof()/string literal). /// - public static string GetHandlerMethodName(InvocationExpressionSyntax invocation) + public static string GetHandlerMethodName(InvocationExpressionSyntax invocation, SemanticModel semanticModel) { - var arguments = invocation.ArgumentList.Arguments; - return arguments.Count < 2 ? null : RegisterStepHelper.GetMethodName(arguments[1].Expression); + var handlerArgument = GetHandlerArgument(invocation, semanticModel); + return handlerArgument == null ? null : RegisterStepHelper.GetMethodName(handlerArgument); } /// diff --git a/XrmPluginCore.SourceGenerator/Helpers/RegisterStepHelper.cs b/XrmPluginCore.SourceGenerator/Helpers/RegisterStepHelper.cs index ec3f097..135c36c 100644 --- a/XrmPluginCore.SourceGenerator/Helpers/RegisterStepHelper.cs +++ b/XrmPluginCore.SourceGenerator/Helpers/RegisterStepHelper.cs @@ -34,13 +34,33 @@ public static string GetExpectedImageNamespace( var entityTypeInfo = semanticModel.GetTypeInfo(entityTypeSyntax); var entityTypeName = entityTypeInfo.Type?.Name ?? "Unknown"; - var arguments = invocation.ArgumentList.Arguments; - var operation = ExtractEnumValue(arguments[0].Expression); - var stage = ExtractEnumValue(arguments[1].Expression); + var (operationExpr, stageExpr) = GetOperationAndStageArguments(invocation, semanticModel); + if (operationExpr == null || stageExpr == null) + { + return null; + } + + var operation = ExtractEnumValue(operationExpr); + var stage = ExtractEnumValue(stageExpr); return $"{pluginNamespace}.PluginRegistrations.{pluginClassName}.{entityTypeName}{operation}{stage}"; } + /// + /// Resolves the eventOperation and executionStage argument expressions by parameter + /// name (so named/reordered arguments are honored). Either may be null when the call cannot be + /// resolved. + /// + public static (ExpressionSyntax operation, ExpressionSyntax stage) GetOperationAndStageArguments( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel) + { + var bound = ArgumentBinder.Bind(invocation, semanticModel); + var operation = bound.TryGetValue(Constants.ParameterEventOperation, out var op) ? op : null; + var stage = bound.TryGetValue(Constants.ParameterExecutionStage, out var st) ? st : null; + return (operation, stage); + } + /// /// Extracts the namespace from a syntax node by walking up the tree. /// diff --git a/XrmPluginCore.SourceGenerator/Parsers/CustomApiRegistrationParser.cs b/XrmPluginCore.SourceGenerator/Parsers/CustomApiRegistrationParser.cs index 1c70808..c3f265d 100644 --- a/XrmPluginCore.SourceGenerator/Parsers/CustomApiRegistrationParser.cs +++ b/XrmPluginCore.SourceGenerator/Parsers/CustomApiRegistrationParser.cs @@ -80,7 +80,7 @@ private static CustomApiMetadata ParseRegisterApiInvocation( PluginClassName = classDeclaration.Identifier.Text, ServiceTypeName = serviceType.Name, ServiceTypeFullName = serviceType.ToDisplayString(), - HandlerMethodName = RegisterApiHelper.GetHandlerMethodName(invocation), + HandlerMethodName = RegisterApiHelper.GetHandlerMethodName(invocation, semanticModel), }; foreach (var parameterCall in RegisterApiHelper.FindParameterInvocations(invocation)) @@ -112,10 +112,10 @@ private static CustomApiParameterMetadata ParseParameterInvocation( SemanticModel semanticModel, bool isRequest) { - var boundArguments = BindArguments(invocation, semanticModel); + var boundArguments = ArgumentBinder.Bind(invocation, semanticModel); // uniqueName (first parameter) - must be a compile-time constant string. - if (!boundArguments.TryGetValue("uniqueName", out var uniqueNameExpr)) + if (!boundArguments.TryGetValue(Constants.ParameterUniqueName, out var uniqueNameExpr)) { return null; } @@ -127,12 +127,12 @@ private static CustomApiParameterMetadata ParseParameterInvocation( } // type (CustomApiParameterType member access, or constant integer fallback). - var parameterType = boundArguments.TryGetValue("type", out var typeExpr) + var parameterType = boundArguments.TryGetValue(Constants.ParameterType, out var typeExpr) ? GetParameterTypeName(typeExpr, semanticModel) : null; var isOptional = isRequest - && boundArguments.TryGetValue("isOptional", out var optionalExpr) + && boundArguments.TryGetValue(Constants.ParameterIsOptional, out var optionalExpr) && semanticModel.GetConstantValue(optionalExpr) is { HasValue: true, Value: true }; var clrType = CustomApiParameterTypeMapper.GetClrType(parameterType); @@ -151,48 +151,6 @@ private static CustomApiParameterMetadata ParseParameterInvocation( }; } - /// - /// Binds each argument expression to its parameter name (honoring named and positional arguments). - /// - private static Dictionary BindArguments( - InvocationExpressionSyntax invocation, - SemanticModel semanticModel) - { - var result = new Dictionary(); - - var symbolInfo = semanticModel.GetSymbolInfo(invocation); - var method = symbolInfo.Symbol as IMethodSymbol - ?? symbolInfo.CandidateSymbols.OfType().FirstOrDefault(); - if (method == null) - { - return result; - } - - var arguments = invocation.ArgumentList.Arguments; - var positional = 0; - foreach (var argument in arguments) - { - IParameterSymbol parameter; - if (argument.NameColon != null) - { - var name = argument.NameColon.Name.Identifier.Text; - parameter = method.Parameters.FirstOrDefault(p => p.Name == name); - } - else - { - parameter = positional < method.Parameters.Length ? method.Parameters[positional] : null; - positional++; - } - - if (parameter != null) - { - result[parameter.Name] = argument.Expression; - } - } - - return result; - } - /// /// Extracts the CustomApiParameterType member name from CustomApiParameterType.String or a /// constant integer. diff --git a/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs b/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs index 5aaedf7..c41825b 100644 --- a/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs +++ b/XrmPluginCore.SourceGenerator/Parsers/RegistrationParser.cs @@ -105,18 +105,26 @@ private static PluginStepMetadata ParseRegisterStepInvocation( metadata.ServiceTypeFullName = serviceType.ToDisplayString(); } - // Extract EventOperation and ExecutionStage from arguments - var arguments = registerStepInvocation.ArgumentList.Arguments; - if (arguments.Count >= 2) + // Resolve EventOperation, ExecutionStage and the handler argument by parameter name so named or + // reordered arguments are honored (positional calls bind to the same parameters). + var boundArguments = ArgumentBinder.Bind(registerStepInvocation, semanticModel); + + if (boundArguments.TryGetValue(Constants.ParameterEventOperation, out var operationExpr)) + { + metadata.EventOperation = ExtractEnumValue(operationExpr); + } + + if (boundArguments.TryGetValue(Constants.ParameterExecutionStage, out var stageExpr)) { - metadata.EventOperation = ExtractEnumValue(arguments[0].Expression); - metadata.ExecutionStage = ExtractEnumValue(arguments[1].Expression); + metadata.ExecutionStage = ExtractEnumValue(stageExpr); } - // Extract method reference from 3rd argument if present - if (arguments.Count >= 3) + // The handler argument is 'handlerMethodName' on the typed overload and 'action' on the + // lambda/action overloads; GetMethodName handles nameof, string literals and lambda bodies. + if (boundArguments.TryGetValue(Constants.ParameterHandlerMethodName, out var handlerExpr) || + boundArguments.TryGetValue(Constants.ParameterAction, out handlerExpr)) { - metadata.HandlerMethodName = RegisterStepHelper.GetMethodName(arguments[2].Expression); + metadata.HandlerMethodName = RegisterStepHelper.GetMethodName(handlerExpr); } // Find image calls