diff --git a/CLAUDE.md b/CLAUDE.md index 246b94d..f866818 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -289,6 +289,57 @@ 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 | +| --- | --- | --- | +| 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) | +| 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..a3519a7 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 @@ -201,9 +202,54 @@ 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 | +| [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..865a03f --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/CustomApiHandlerDiagnosticsTests.cs @@ -0,0 +1,258 @@ +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_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_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() + { + 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/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 new file mode 100644 index 0000000..6e45ff0 --- /dev/null +++ b/XrmPluginCore.SourceGenerator.Tests/GenerationTests/CustomApiClassGenerationTests.cs @@ -0,0 +1,333 @@ +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("this.StatusCode = statusCode;"); + generated.Should().Contain("this.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();"); + // 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;"); + } + + [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_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("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 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 + 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] + 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_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_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() + { + // 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 87bcb9e..077e5b3 100644 --- a/XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs +++ b/XrmPluginCore.SourceGenerator.Tests/Helpers/TestFixtures.cs @@ -416,6 +416,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..bf6865e 100644 --- a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md @@ -2,6 +2,10 @@ 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) ### Removed Rules diff --git a/XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerMethodNotFoundAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerMethodNotFoundAnalyzer.cs new file mode 100644 index 0000000..c9e7bce --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerMethodNotFoundAnalyzer.cs @@ -0,0 +1,88 @@ +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 handlerArgument = RegisterApiHelper.GetHandlerArgument(invocation, context.SemanticModel); + if (handlerArgument == null) + { + return; + } + + var methodName = RegisterApiHelper.GetHandlerMethodName(invocation, context.SemanticModel); + 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..6f14bbd --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Analyzers/CustomApiHandlerSignatureMismatchAnalyzer.cs @@ -0,0 +1,167 @@ +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 handlerArgument = RegisterApiHelper.GetHandlerArgument(invocation, context.SemanticModel); + if (handlerArgument == null) + { + return; + } + + var methodName = RegisterApiHelper.GetHandlerMethodName(invocation, context.SemanticModel); + 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 && !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 && !IsGeneratedType(method.ReturnType, generation.PluginNamespace, generation.ResponseClassName)) + { + return false; + } + + 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; + + 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/CustomApiNameNotConstantAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/CustomApiNameNotConstantAnalyzer.cs new file mode 100644 index 0000000..fc53425 --- /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 nameArgument = RegisterApiHelper.GetNameArgument(invocation, context.SemanticModel); + if (nameArgument == null) + { + return; + } + + // A resolvable constant name means generation can proceed. + if (RegisterApiHelper.GetApiName(invocation, context.SemanticModel) != null) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.CustomApiNameNotConstant, + nameArgument.GetLocation())); + } +} 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/Analyzers/PreferNameofAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/PreferNameofAnalyzer.cs index 3ada889..f8f96d5 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,57 @@ 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; + } + + // 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 handler; + } + + if (RegisterApiHelper.IsRegisterApiCall(invocation, out var apiGeneric) && + RegisterApiHelper.IsTypedHandlerOverload(invocation, semanticModel)) + { + if (apiGeneric.TypeArgumentList.Arguments.Count < 1) + { + return null; + } + + var handler = RegisterApiHelper.GetHandlerArgument(invocation, semanticModel); + if (handler == null) + { + return null; + } + + serviceType = apiGeneric.TypeArgumentList.Arguments[0].ToString(); + return handler; + } + + 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..0af0e80 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/CodeGeneration/CustomApiClassGenerator.cs @@ -0,0 +1,210 @@ +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis.CSharp; +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 +{ + /// + /// 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)} {EscapeIdentifier(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)} {EscapeIdentifier(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) + { + // 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}}}"); + + 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}();"); + foreach (var parameter in metadata.RequestParameters) + { + // 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(); + + 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.{EscapeIdentifier(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; + } + + /// + /// 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; + + /// + /// 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)) + { + return propertyName; + } + + var camelCase = char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1); + return EscapeIdentifier(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 868be7c..2935837 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..4f59878 100644 --- a/XrmPluginCore.SourceGenerator/Constants.cs +++ b/XrmPluginCore.SourceGenerator/Constants.cs @@ -16,6 +16,27 @@ 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"; + + // 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"; + public const string ActionWrapperClassSuffix = "ActionWrapper"; + // Image types (concrete generated wrapper class names) public const string PreImageTypeName = "PreImage"; public const string PostImageTypeName = "PostImage"; @@ -32,4 +53,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..f3864f7 100644 --- a/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs +++ b/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs @@ -98,6 +98,43 @@ 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", + 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/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/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..3eeb4cc --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Helpers/IdentifierHelper.cs @@ -0,0 +1,66 @@ +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 + 1); + foreach (var c in value) + { + // 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; prefix with '_' so the original digit is preserved. + 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..4dae775 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Helpers/RegisterApiHelper.cs @@ -0,0 +1,160 @@ +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; + } + + /// + /// 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 nameArgument = GetNameArgument(invocation, semanticModel); + if (nameArgument == null) + { + return null; + } + + var constant = semanticModel.GetConstantValue(nameArgument); + return constant.HasValue ? constant.Value as string : null; + } + + /// + /// Extracts the handler method name from the handlerMethodName argument (nameof()/string literal). + /// + public static string GetHandlerMethodName(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + { + var handlerArgument = GetHandlerArgument(invocation, semanticModel); + return handlerArgument == null ? null : RegisterStepHelper.GetMethodName(handlerArgument); + } + + /// + /// 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/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/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 bd01234..4928e45 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..c3f265d --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Parsers/CustomApiRegistrationParser.cs @@ -0,0 +1,188 @@ +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, semanticModel), + }; + + 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 = ArgumentBinder.Bind(invocation, semanticModel); + + // uniqueName (first parameter) - must be a compile-time constant string. + if (!boundArguments.TryGetValue(Constants.ParameterUniqueName, 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(Constants.ParameterType, out var typeExpr) + ? GetParameterTypeName(typeExpr, semanticModel) + : null; + + var isOptional = isRequest + && boundArguments.TryGetValue(Constants.ParameterIsOptional, 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, + }; + } + + /// + /// 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 778b70d..c41825b 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) @@ -102,24 +105,32 @@ 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 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 +148,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 +236,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 +260,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 +270,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 +284,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 +305,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 }; @@ -425,6 +440,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.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.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/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/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.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.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/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..16e16bf --- /dev/null +++ b/XrmPluginCore.Tests/TypeSafeCustomApiTests.cs @@ -0,0 +1,109 @@ +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 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() + { + // 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 d00eb0e..9005c51 100644 --- a/XrmPluginCore/CHANGELOG.md +++ b/XrmPluginCore/CHANGELOG.md @@ -1,5 +1,12 @@ -### v1.3.1 - 30 June 2026 +### 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. +* 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. ### 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. 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..84ffeb9 --- /dev/null +++ b/XrmPluginCore/Helpers/IdentifierSanitizer.cs @@ -0,0 +1,40 @@ +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 + 1); + foreach (var c in value) + { + // 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; prefix with '_' so the original digit is preserved. + if (char.IsDigit(value[0])) + { + sb.Insert(0, '_'); + } + + return sb.ToString(); + } + } +} diff --git a/XrmPluginCore/Plugin.cs b/XrmPluginCore/Plugin.cs index 4fafbb3..83bf7b6 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; @@ -21,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; } /// @@ -271,11 +279,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 +332,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 = + $"{ChildClassNamespace}.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 +350,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 +391,49 @@ 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 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"); + } + + var configBuilder = new CustomApiConfigBuilder(name); + RegisteredCustomApi = new CustomApiRegistration(configBuilder, handlerMethodName); + + return configBuilder; + } + /// /// /// Register a CustomAPI with the given name and action.
@@ -415,13 +464,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 = $"{ChildClassNamespace}.{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; }