From d81b80f20af5158d6324706effbb168c86712437 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Thu, 2 Jul 2026 15:30:13 +0200 Subject: [PATCH 1/2] Add XPC3007: suggest type-safe Custom API handler overload When a Custom API declares AddRequestParameter/AddResponseProperty but registers with the action-based RegisterAPI(name, Action) overload, XPC3007 (Info) now suggests the type-safe RegisterAPI(name, nameof(TService.Handler)) overload that generates strongly-typed request/response DTOs, mirroring the type-safe image support for plugin steps. Includes a code fix that converts the lambda to nameof(...), after which XPC4005/XPC4006 guide the handler signature. Also: - Move the already-shipped Custom API rules (XPC3006/XPC4004/XPC4005/XPC4006) from AnalyzerReleases.Unshipped.md to Shipped.md under Release 1.4.1. - Mark the pending CHANGELOG entry as "v1.4.2 - Unreleased" and document the unreleased-entry and analyzer release-tracking conventions in CLAUDE.md. Co-Authored-By: Claude via Conducktor --- CLAUDE.md | 5 ++ .../CustomApiHandlerDiagnosticsTests.cs | 76 ++++++++++++++++ .../AnalyzerReleases.Shipped.md | 11 +++ .../AnalyzerReleases.Unshipped.md | 5 +- .../PreferTypedCustomApiHandlerAnalyzer.cs | 78 ++++++++++++++++ ...ferTypedCustomApiHandlerCodeFixProvider.cs | 81 +++++++++++++++++ .../DiagnosticDescriptors.cs | 10 +++ .../Helpers/RegisterApiHelper.cs | 50 +++++++++++ .../rules/XPC3007.md | 89 +++++++++++++++++++ XrmPluginCore/CHANGELOG.md | 3 + 10 files changed, 404 insertions(+), 4 deletions(-) create mode 100644 XrmPluginCore.SourceGenerator/Analyzers/PreferTypedCustomApiHandlerAnalyzer.cs create mode 100644 XrmPluginCore.SourceGenerator/CodeFixes/PreferTypedCustomApiHandlerCodeFixProvider.cs create mode 100644 XrmPluginCore.SourceGenerator/rules/XPC3007.md diff --git a/CLAUDE.md b/CLAUDE.md index 04b1b94..501a33b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -338,6 +338,7 @@ public class CallbackService | 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) | +| XPC3007 | Info | A Custom API declares request/response parameters but uses the action-based overload; prefer the typed `RegisterAPI(name, nameof(TService.Method))` overload that generates DTOs (code fix converts the lambda to `nameof`) | | XPC3001 | Warning | Prefer `nameof(TService.Method)` over a string literal for the handler argument | ### Dependency Injection @@ -491,6 +492,10 @@ Version numbers are managed through CHANGELOG.md files: `Directory.Build.targets` derives each project's `` from the latest `### v...` entry in the relevant CHANGELOG.md at build time — no script or CI step required. Core and the source generator share `XrmPluginCore/CHANGELOG.md`; Abstractions overrides `ChangelogPath` in its .csproj to use its own changelog. An explicit `-p:Version=...` still overrides the derived value. +The pending (not-yet-released) entry is marked `### vX.Y.Z - Unreleased` instead of a date. Only the version number is parsed (the ` - Unreleased`/date suffix is ignored), so this doesn't affect the derived ``. Add new changes to that existing `- Unreleased` entry rather than creating a new heading; when the version is released, replace `Unreleased` with the release date. + +Analyzer rules are tracked separately in `XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md`; new rules go there and are moved to `AnalyzerReleases.Shipped.md` (under a `## Release X.Y.Z` heading) when that version is released. + ## Source Generator Development When developing or debugging the source generator, use the following workflow to test changes against a local project: diff --git a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/CustomApiHandlerDiagnosticsTests.cs b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/CustomApiHandlerDiagnosticsTests.cs index 865a03f..102e7bd 100644 --- a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/CustomApiHandlerDiagnosticsTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/CustomApiHandlerDiagnosticsTests.cs @@ -175,6 +175,82 @@ public async Task Should_Report_XPC3001_For_String_Literal_Handler() diagnostic.Properties["MethodName"].Should().Be("Handle"); } + [Fact] + public async Task Should_Report_XPC3007_For_Action_Overload_With_Parameters() + { + const string registration = """ + RegisterAPI(nameof(SomeApi), s => s.Handle()) + .AddRequestParameter("EntityId", CustomApiParameterType.Guid) + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer); + """; + var source = WrapPlugin(registration, serviceBody: "public void Handle() { }"); + + var diagnostics = await GetDiagnosticsAsync(source, new PreferTypedCustomApiHandlerAnalyzer()); + + var diagnostic = diagnostics.Should().ContainSingle(d => d.Id == "XPC3007").Subject; + diagnostic.Severity.Should().Be(DiagnosticSeverity.Info); + diagnostic.Properties["ServiceType"].Should().Be("CallbackService"); + diagnostic.Properties["MethodName"].Should().Be("Handle"); + } + + [Fact] + public async Task Should_Report_XPC3007_When_Only_Response_Declared() + { + const string registration = """ + RegisterAPI(nameof(SomeApi), s => s.Handle()) + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer); + """; + var source = WrapPlugin(registration, serviceBody: "public void Handle() { }"); + + var diagnostics = await GetDiagnosticsAsync(source, new PreferTypedCustomApiHandlerAnalyzer()); + + diagnostics.Should().ContainSingle(d => d.Id == "XPC3007"); + } + + [Fact] + public async Task Should_Not_Report_XPC3007_When_No_Parameters_Declared() + { + const string registration = """ + RegisterAPI(nameof(SomeApi), s => s.Handle()); + """; + var source = WrapPlugin(registration, serviceBody: "public void Handle() { }"); + + var diagnostics = await GetDiagnosticsAsync(source, new PreferTypedCustomApiHandlerAnalyzer()); + + diagnostics.Should().NotContain(d => d.Id == "XPC3007"); + } + + [Fact] + public async Task Should_Not_Report_XPC3007_For_Typed_Handler_Overload() + { + var source = WrapPlugin(RegistrationWithParams, serviceBody: "public SomeApiResponse Handle(SomeApiRequest request) => new SomeApiResponse(0);") + + GeneratedTypes; + + var diagnostics = await GetDiagnosticsAsync(source, new PreferTypedCustomApiHandlerAnalyzer()); + + diagnostics.Should().NotContain(d => d.Id == "XPC3007"); + } + + [Fact] + public async Task Should_Fix_Action_Overload_To_Nameof() + { + const string registration = """ + RegisterAPI(nameof(SomeApi), s => s.Handle()) + .AddRequestParameter("EntityId", CustomApiParameterType.Guid) + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer); + """; + var source = WrapPlugin(registration, serviceBody: "public void Handle() { }"); + + var fixedSource = await ApplyCodeFixAsync( + source, + new PreferTypedCustomApiHandlerAnalyzer(), + new PreferTypedCustomApiHandlerCodeFixProvider(), + DiagnosticDescriptors.PreferTypedCustomApiHandler.Id); + + fixedSource.Should().Contain("nameof(CallbackService.Handle)"); + fixedSource.Should().NotContain("s => s.Handle()"); + } + [Fact] public async Task Should_Fix_Missing_Handler_Method() { diff --git a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Shipped.md b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Shipped.md index 161fc78..f497569 100644 --- a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Shipped.md +++ b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Shipped.md @@ -1,3 +1,14 @@ +## Release 1.4.1 + +### New Rules + +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) + ## Release 1.2.7 ### New Rules diff --git a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md index bf6865e..6807015 100644 --- a/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md @@ -2,10 +2,7 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- -XPC3006 | XrmPluginCore.SourceGenerator | Warning | XPC3006 Custom API name must be a compile-time constant -XPC4004 | XrmPluginCore.SourceGenerator | Error | XPC4004 Custom API handler method not found -XPC4005 | XrmPluginCore.SourceGenerator | Warning | XPC4005 Custom API handler signature mismatch (generated types don't exist) -XPC4006 | XrmPluginCore.SourceGenerator | Error | XPC4006 Custom API handler signature mismatch (generated types exist) +XPC3007 | XrmPluginCore.SourceGenerator | Info | XPC3007 Prefer the type-safe Custom API handler overload when request/response parameters are declared ### Removed Rules diff --git a/XrmPluginCore.SourceGenerator/Analyzers/PreferTypedCustomApiHandlerAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/PreferTypedCustomApiHandlerAnalyzer.cs new file mode 100644 index 0000000..f9a7d3b --- /dev/null +++ b/XrmPluginCore.SourceGenerator/Analyzers/PreferTypedCustomApiHandlerAnalyzer.cs @@ -0,0 +1,78 @@ +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; + +/// +/// Suggests migrating an action-based RegisterAPI<TService>(name, Action<TService>) call that +/// declares AddRequestParameter/AddResponseProperty to the type-safe +/// RegisterAPI<TService>(name, nameof(TService.Handler)) overload, which generates strongly-typed +/// request/response DTOs (mirroring the type-safe image support for plugin steps). +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class PreferTypedCustomApiHandlerAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticDescriptors.PreferTypedCustomApiHandler); + + 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 generic action overload is a candidate: the typed handler-name overload already + // generates DTOs, and the non-generic overloads have no TService to point nameof(...) at. + if (!RegisterApiHelper.IsRegisterApiCall(invocation, out var genericName) || + !RegisterApiHelper.IsActionOverload(invocation, context.SemanticModel)) + { + return; + } + + if (genericName.TypeArgumentList.Arguments.Count < 1) + { + return; + } + + // Only suggest the typed overload when there is actually a request/response shape to generate. + var (hasRequest, hasResponse) = RegisterApiHelper.CheckForParameters(invocation); + if (!hasRequest && !hasResponse) + { + return; + } + + var actionArgument = RegisterApiHelper.GetActionArgument(invocation, context.SemanticModel); + if (actionArgument == null) + { + return; + } + + var serviceType = genericName.TypeArgumentList.Arguments[0].ToString(); + + // Pass the handler method name to the code fix when it can be extracted from the lambda body; + // when it can't (e.g. a statement block), the diagnostic still reports but no automatic fix is offered. + var properties = ImmutableDictionary.CreateBuilder(); + properties.Add(Constants.PropertyServiceType, serviceType); + + var methodName = RegisterApiHelper.GetActionMethodName(actionArgument); + if (methodName != null) + { + properties.Add(Constants.PropertyMethodName, methodName); + } + + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.PreferTypedCustomApiHandler, + actionArgument.GetLocation(), + properties.ToImmutable(), + serviceType)); + } +} diff --git a/XrmPluginCore.SourceGenerator/CodeFixes/PreferTypedCustomApiHandlerCodeFixProvider.cs b/XrmPluginCore.SourceGenerator/CodeFixes/PreferTypedCustomApiHandlerCodeFixProvider.cs new file mode 100644 index 0000000..7ff9a24 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/CodeFixes/PreferTypedCustomApiHandlerCodeFixProvider.cs @@ -0,0 +1,81 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +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; + +/// +/// Converts an action-based Custom API handler (e.g. s => s.Handle()) to the type-safe +/// nameof(TService.Handle) form so the source generator emits strongly-typed request/response DTOs. +/// The follow-up signature adjustment is then guided by XPC4005/XPC4006 and their code fix. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(PreferTypedCustomApiHandlerCodeFixProvider)), Shared] +public class PreferTypedCustomApiHandlerCodeFixProvider : CodeFixProvider +{ + public sealed override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create(DiagnosticDescriptors.PreferTypedCustomApiHandler.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(); + + // Only offer the fix when the analyzer could extract the handler method name from the lambda. + if (!diagnostic.Properties.TryGetValue(Constants.PropertyServiceType, out var serviceType) || + !diagnostic.Properties.TryGetValue(Constants.PropertyMethodName, out var methodName) || + string.IsNullOrEmpty(methodName)) + { + return; + } + + var node = root.FindNode(diagnostic.Location.SourceSpan); + var actionArgument = node as ExpressionSyntax + ?? node.DescendantNodesAndSelf().OfType().FirstOrDefault(); + if (actionArgument == null) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + title: $"Use nameof({serviceType}.{methodName})", + createChangedDocument: c => ConvertToNameofAsync(context.Document, actionArgument, serviceType, methodName, c), + equivalenceKey: nameof(PreferTypedCustomApiHandlerCodeFixProvider)), + diagnostic); + } + + private static async Task ConvertToNameofAsync( + Document document, + ExpressionSyntax actionArgument, + string serviceType, + string methodName, + CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root == null) + { + return document; + } + + var nameofExpression = SyntaxFactoryHelper.CreateNameofExpression(serviceType, methodName) + .WithTriviaFrom(actionArgument); + + var newRoot = root.ReplaceNode(actionArgument, nameofExpression); + return document.WithSyntaxRoot(newRoot); + } +} diff --git a/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs b/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs index f3864f7..54f7587 100644 --- a/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs +++ b/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs @@ -108,6 +108,16 @@ public static class DiagnosticDescriptors 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 PreferTypedCustomApiHandler = new( + id: "XPC3007", + title: "Prefer the type-safe Custom API handler overload", + messageFormat: "This Custom API declares request/response parameters; use 'RegisterAPI<{0}>(name, nameof({0}.Handler))' to generate strongly-typed request/response DTOs instead of the action-based overload", + category: Category, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "When a Custom API declares AddRequestParameter/AddResponseProperty, the typed RegisterAPI(name, handlerMethodName) overload generates strongly-typed {ApiName}Request/{ApiName}Response classes and marshals InputParameters/OutputParameters for you. The action-based overload leaves you to read and write those dictionaries by hand.", + helpLinkUri: $"{HelpLinkBaseUri}/XPC3007.md"); + public static readonly DiagnosticDescriptor CustomApiHandlerMethodNotFound = new( id: "XPC4004", title: "Custom API handler method not found", diff --git a/XrmPluginCore.SourceGenerator/Helpers/RegisterApiHelper.cs b/XrmPluginCore.SourceGenerator/Helpers/RegisterApiHelper.cs index 4dae775..55c7a90 100644 --- a/XrmPluginCore.SourceGenerator/Helpers/RegisterApiHelper.cs +++ b/XrmPluginCore.SourceGenerator/Helpers/RegisterApiHelper.cs @@ -52,6 +52,19 @@ public static bool IsTypedHandlerOverload(InvocationExpressionSyntax invocation, && method.Parameters[1].Type.SpecialType == SpecialType.System_String; } + /// + /// Determines whether the invocation binds to a generic action overload + /// RegisterAPI<TService>(string name, Action<TService> action) (vs the typed handler-name + /// overload). This is the overload that does NOT generate request/response DTOs. + /// + public static bool IsActionOverload(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + { + var method = ResolveMethod(invocation, semanticModel); + return method != null + && method.Parameters.Length == 2 + && method.Parameters[1].Type.TypeKind == TypeKind.Delegate; + } + /// /// Returns the name argument expression, resolved by parameter name (so named/reordered /// arguments are honored). Returns null when absent. @@ -59,6 +72,43 @@ public static bool IsTypedHandlerOverload(InvocationExpressionSyntax invocation, public static ExpressionSyntax GetNameArgument(InvocationExpressionSyntax invocation, SemanticModel semanticModel) => ArgumentBinder.GetArgument(invocation, semanticModel, Constants.ParameterName); + /// + /// Returns the action argument expression (the delegate/lambda), resolved by parameter name. + /// Returns null when absent. + /// + public static ExpressionSyntax GetActionArgument(InvocationExpressionSyntax invocation, SemanticModel semanticModel) + => ArgumentBinder.GetArgument(invocation, semanticModel, Constants.ParameterAction); + + /// + /// Extracts the handler method name from an action lambda such as s => s.Handle() or + /// s => s.Handle. Returns null when the lambda body is not a single call to a member of the + /// lambda parameter (e.g. a statement block or a more complex expression). + /// + public static string GetActionMethodName(ExpressionSyntax actionArgument) + { + var body = actionArgument switch + { + SimpleLambdaExpressionSyntax simpleLambda => simpleLambda.Body, + ParenthesizedLambdaExpressionSyntax parenLambda => parenLambda.Body, + _ => null, + }; + + // s => s.Handle() — body is an invocation of a member access + if (body is InvocationExpressionSyntax bodyInvocation && + bodyInvocation.Expression is MemberAccessExpressionSyntax invokedMember) + { + return invokedMember.Name.Identifier.Text; + } + + // s => s.Handle — body is a bare member access (method group) + if (body is MemberAccessExpressionSyntax memberAccess) + { + return memberAccess.Name.Identifier.Text; + } + + return null; + } + /// /// Returns the handlerMethodName argument expression, resolved by parameter name. Returns null /// when absent. diff --git a/XrmPluginCore.SourceGenerator/rules/XPC3007.md b/XrmPluginCore.SourceGenerator/rules/XPC3007.md new file mode 100644 index 0000000..b782920 --- /dev/null +++ b/XrmPluginCore.SourceGenerator/rules/XPC3007.md @@ -0,0 +1,89 @@ +# XPC3007: Prefer the type-safe Custom API handler overload + +## Severity + +Info + +## Description + +When a Custom API declares request parameters (`AddRequestParameter`) or response properties +(`AddResponseProperty`), the type-safe `RegisterAPI(name, handlerMethodName)` overload +generates strongly-typed `{ApiName}Request`/`{ApiName}Response` classes (named after the API, in the +plugin's namespace) and a generated `ActionWrapper` that marshals `InputParameters` into the request +and the returned response into `OutputParameters`. + +The action-based `RegisterAPI(name, Action)` overload does **not** generate these +DTOs, so the handler is left to read `InputParameters` and write `OutputParameters` by hand — losing +compile-time safety, IntelliSense, and refactoring support. + +This is the Custom API counterpart of the type-safe image support for plugin steps +(`WithPreImage`/`WithPostImage` + `nameof`). + +## ❌ Example of violation + +```csharp +public class SomeCustomApi : Plugin +{ + public SomeCustomApi() + { + // XPC3007: parameters are declared, but the action overload generates no DTOs + RegisterAPI(nameof(SomeCustomApi), s => s.Handle()) + .AddRequestParameter("EntityId", CustomApiParameterType.Guid) + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + => services.AddScoped(); +} + +public class CallbackService +{ + public void Handle() { /* manually reads InputParameters / writes OutputParameters */ } +} +``` + +## ✅ How to fix + +Switch to the typed handler-name overload with `nameof(...)`. The source generator then emits the +request/response types, and the handler accepts the request and returns the response: + +```csharp +public class SomeCustomApi : Plugin +{ + public SomeCustomApi() + { + RegisterAPI(nameof(SomeCustomApi), nameof(CallbackService.Handle)) + .AddRequestParameter("EntityId", CustomApiParameterType.Guid) + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + => services.AddScoped(); +} + +public class CallbackService +{ + public SomeCustomApiResponse Handle(SomeCustomApiRequest request) + { + var id = request.EntityId; // strongly-typed + return new SomeCustomApiResponse(200); // written to OutputParameters for you + } +} +``` + +The provided code fix converts `s => s.Handle()` to `nameof(CallbackService.Handle)`. After applying +it, XPC4005/XPC4006 (and their code fix) guide the handler signature to accept the generated request +and return the generated response. + +## Note + +This diagnostic is a suggestion (Info) — the action-based overload remains fully supported. It fires +only when at least one `AddRequestParameter` or `AddResponseProperty` is present, because there is no +DTO to generate otherwise. + +## See also + +- [XPC3006: Custom API name must be a compile-time constant](XPC3006.md) +- [XPC4004: Custom API handler method not found](XPC4004.md) +- [XPC4005: Custom API handler signature mismatch](XPC4005.md) +- [XPC4006: Custom API handler signature mismatch](XPC4006.md) diff --git a/XrmPluginCore/CHANGELOG.md b/XrmPluginCore/CHANGELOG.md index ab27c9a..2cecf83 100644 --- a/XrmPluginCore/CHANGELOG.md +++ b/XrmPluginCore/CHANGELOG.md @@ -1,3 +1,6 @@ +### v1.4.2 - Unreleased +* Add: Info XPC3007: when a Custom API declares `AddRequestParameter`/`AddResponseProperty` but registers with the action-based `RegisterAPI(name, Action)` overload, it now suggests the type-safe `RegisterAPI(name, nameof(TService.Handler))` overload that generates strongly-typed request/response DTOs (with a code fix that converts `s => s.Handle()` to `nameof(TService.Handle)`). + ### v1.4.1 - 01 July 2026 * Add: `WithExecutePrivilege(Privilege)` on the Custom API builder for type-safe execute-privilege configuration, e.g. `.WithExecutePrivilege(Privilege.Read)` resolves to `prvReadAccount`. Privilege names use the entity schema name, taken from the early-bound type name (`typeof(T).Name`). A `WithExecutePrivilege(string entitySchemaName, Privilege)` overload is available for late-bound scenarios, and the existing `WithExecutePrivilegeName(string)` remains for non-standard privilege names. * 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`. From abdba1e3797c0b1376300ba2570bcf5730453c9a Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Fri, 3 Jul 2026 08:52:38 +0200 Subject: [PATCH 2/2] Address Copilot review on XPC3007 - GetActionMethodName: only extract the handler name when the lambda calls a method directly on the service parameter (s => s.Handle()), not on a different receiver (s => Other.Handle()) or a nested access (s => s.Foo.Handle()), so the code fix never offers a misleading nameof(TService.X). - CreateNameofExpression: parse the service type as a name so qualified (My.Namespace.Service) and generic (Service) type names produce valid nameof syntax. This also fixes the shipped XPC3001 code fix, which shares the helper. - XPC3007 message: drop the hardcoded ".Handler" and recommend the typed overload with nameof(...) without implying a specific method name. - Tests + CHANGELOG updated accordingly. Co-Authored-By: Claude via Conducktor --- .../CustomApiHandlerDiagnosticsTests.cs | 59 +++++++++++++++++++ .../DiagnosticDescriptors.cs | 2 +- .../Helpers/RegisterApiHelper.cs | 43 +++++++++----- .../Helpers/SyntaxFactoryHelper.cs | 6 +- XrmPluginCore/CHANGELOG.md | 3 +- 5 files changed, 95 insertions(+), 18 deletions(-) diff --git a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/CustomApiHandlerDiagnosticsTests.cs b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/CustomApiHandlerDiagnosticsTests.cs index 102e7bd..87e3826 100644 --- a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/CustomApiHandlerDiagnosticsTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/CustomApiHandlerDiagnosticsTests.cs @@ -231,6 +231,65 @@ public async Task Should_Not_Report_XPC3007_For_Typed_Handler_Overload() diagnostics.Should().NotContain(d => d.Id == "XPC3007"); } + [Fact] + public async Task Should_Report_XPC3007_Without_MethodName_When_Call_Is_Not_On_Lambda_Parameter() + { + // The lambda calls a method on a different receiver, not the service parameter, so no + // nameof(CallbackService.X) can be safely suggested: report the diagnostic but offer no fix. + const string source = """ + using XrmPluginCore; + using XrmPluginCore.Enums; + using Microsoft.Extensions.DependencyInjection; + + namespace TestNamespace + { + public static class Other { public static void Handle() { } } + + public class SomeApi : Plugin + { + public SomeApi() + { + RegisterAPI(nameof(SomeApi), s => Other.Handle()) + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer); + } + + protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) + => services.AddScoped(); + } + + public class CallbackService + { + public void Handle() { } + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source, new PreferTypedCustomApiHandlerAnalyzer()); + + var diagnostic = diagnostics.Should().ContainSingle(d => d.Id == "XPC3007").Subject; + diagnostic.Properties.ContainsKey("MethodName").Should().BeFalse(); + } + + [Fact] + public async Task Should_Fix_Action_Overload_With_Qualified_Service_Type() + { + // A namespace-qualified generic argument must produce valid nameof syntax (not a single + // malformed identifier). + const string registration = """ + RegisterAPI(nameof(SomeApi), s => s.Handle()) + .AddResponseProperty("StatusCode", CustomApiParameterType.Integer); + """; + var source = WrapPlugin(registration, serviceBody: "public void Handle() { }"); + + var fixedSource = await ApplyCodeFixAsync( + source, + new PreferTypedCustomApiHandlerAnalyzer(), + new PreferTypedCustomApiHandlerCodeFixProvider(), + DiagnosticDescriptors.PreferTypedCustomApiHandler.Id); + + fixedSource.Should().Contain("nameof(TestNamespace.CallbackService.Handle)"); + } + [Fact] public async Task Should_Fix_Action_Overload_To_Nameof() { diff --git a/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs b/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs index 54f7587..cb5aefe 100644 --- a/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs +++ b/XrmPluginCore.SourceGenerator/DiagnosticDescriptors.cs @@ -111,7 +111,7 @@ public static class DiagnosticDescriptors public static readonly DiagnosticDescriptor PreferTypedCustomApiHandler = new( id: "XPC3007", title: "Prefer the type-safe Custom API handler overload", - messageFormat: "This Custom API declares request/response parameters; use 'RegisterAPI<{0}>(name, nameof({0}.Handler))' to generate strongly-typed request/response DTOs instead of the action-based overload", + messageFormat: "This Custom API declares request/response parameters; use the typed 'RegisterAPI<{0}>(name, nameof(...))' overload to generate strongly-typed request/response DTOs instead of the action-based overload", category: Category, defaultSeverity: DiagnosticSeverity.Info, isEnabledByDefault: true, diff --git a/XrmPluginCore.SourceGenerator/Helpers/RegisterApiHelper.cs b/XrmPluginCore.SourceGenerator/Helpers/RegisterApiHelper.cs index 55c7a90..5b7f8c4 100644 --- a/XrmPluginCore.SourceGenerator/Helpers/RegisterApiHelper.cs +++ b/XrmPluginCore.SourceGenerator/Helpers/RegisterApiHelper.cs @@ -81,27 +81,42 @@ public static ExpressionSyntax GetActionArgument(InvocationExpressionSyntax invo /// /// Extracts the handler method name from an action lambda such as s => s.Handle() or - /// s => s.Handle. Returns null when the lambda body is not a single call to a member of the - /// lambda parameter (e.g. a statement block or a more complex expression). + /// s => s.Handle, but only when the member is accessed directly on the lambda parameter. + /// Returns null for statement blocks, more complex expressions, or calls whose receiver is not the + /// lambda parameter (e.g. s => Other.Handle() or s => s.Foo.Handle()), so the + /// code fix never offers a misleading nameof(TService.X) for a method that isn't on TService. /// public static string GetActionMethodName(ExpressionSyntax actionArgument) { - var body = actionArgument switch + string parameterName; + ExpressionSyntax body; + switch (actionArgument) { - SimpleLambdaExpressionSyntax simpleLambda => simpleLambda.Body, - ParenthesizedLambdaExpressionSyntax parenLambda => parenLambda.Body, - _ => null, - }; + case SimpleLambdaExpressionSyntax simpleLambda: + parameterName = simpleLambda.Parameter.Identifier.Text; + body = simpleLambda.Body as ExpressionSyntax; + break; + case ParenthesizedLambdaExpressionSyntax parenLambda when parenLambda.ParameterList.Parameters.Count == 1: + parameterName = parenLambda.ParameterList.Parameters[0].Identifier.Text; + body = parenLambda.Body as ExpressionSyntax; + break; + default: + return null; + } - // s => s.Handle() — body is an invocation of a member access - if (body is InvocationExpressionSyntax bodyInvocation && - bodyInvocation.Expression is MemberAccessExpressionSyntax invokedMember) + // Unwrap an invocation body (s => s.Handle()) to its member access; a bare member access body + // (s => s.Handle, a method group) is used directly. + var memberAccess = body switch { - return invokedMember.Name.Identifier.Text; - } + InvocationExpressionSyntax invocation => invocation.Expression as MemberAccessExpressionSyntax, + MemberAccessExpressionSyntax bareMember => bareMember, + _ => null, + }; - // s => s.Handle — body is a bare member access (method group) - if (body is MemberAccessExpressionSyntax memberAccess) + // Only accept when the receiver is exactly the lambda parameter (s.Handle), not a different + // target (Other.Handle) or a nested access (s.Foo.Handle). + if (memberAccess?.Expression is IdentifierNameSyntax receiver && + receiver.Identifier.Text == parameterName) { return memberAccess.Name.Identifier.Text; } diff --git a/XrmPluginCore.SourceGenerator/Helpers/SyntaxFactoryHelper.cs b/XrmPluginCore.SourceGenerator/Helpers/SyntaxFactoryHelper.cs index 876e842..1eaa5ea 100644 --- a/XrmPluginCore.SourceGenerator/Helpers/SyntaxFactoryHelper.cs +++ b/XrmPluginCore.SourceGenerator/Helpers/SyntaxFactoryHelper.cs @@ -12,7 +12,9 @@ namespace XrmPluginCore.SourceGenerator.Helpers; internal static class SyntaxFactoryHelper { /// - /// Creates a nameof(ServiceType.MethodName) expression. + /// Creates a nameof(ServiceType.MethodName) expression. is parsed + /// as a name so qualified (My.Namespace.Service) and generic (Service<T>) type + /// names produce valid syntax rather than a single malformed identifier. /// public static InvocationExpressionSyntax CreateNameofExpression(string serviceType, string methodName) { @@ -23,7 +25,7 @@ public static InvocationExpressionSyntax CreateNameofExpression(string serviceTy SyntaxFactory.Argument( SyntaxFactory.MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, - SyntaxFactory.IdentifierName(serviceType), + SyntaxFactory.ParseName(serviceType), SyntaxFactory.IdentifierName(methodName)))))); } diff --git a/XrmPluginCore/CHANGELOG.md b/XrmPluginCore/CHANGELOG.md index 2cecf83..5e0031b 100644 --- a/XrmPluginCore/CHANGELOG.md +++ b/XrmPluginCore/CHANGELOG.md @@ -1,5 +1,6 @@ ### v1.4.2 - Unreleased -* Add: Info XPC3007: when a Custom API declares `AddRequestParameter`/`AddResponseProperty` but registers with the action-based `RegisterAPI(name, Action)` overload, it now suggests the type-safe `RegisterAPI(name, nameof(TService.Handler))` overload that generates strongly-typed request/response DTOs (with a code fix that converts `s => s.Handle()` to `nameof(TService.Handle)`). +* Add: Info XPC3007: when a Custom API declares `AddRequestParameter`/`AddResponseProperty` but registers with the action-based `RegisterAPI(name, Action)` overload, it now suggests the type-safe `RegisterAPI(name, nameof(...))` overload that generates strongly-typed request/response DTOs. The accompanying code fix converts `s => s.Handle()` to `nameof(TService.Handle)`, and only offers the fix when the lambda calls a method directly on the service parameter (so `s => Other.Handle()` reports the suggestion without a misleading fix). +* Fix: The `nameof(...)` code fix (XPC3001 and XPC3007) now produces valid syntax when the service type is written with a namespace or generic arguments (e.g. `RegisterAPI`), instead of emitting a malformed single identifier. ### v1.4.1 - 01 July 2026 * Add: `WithExecutePrivilege(Privilege)` on the Custom API builder for type-safe execute-privilege configuration, e.g. `.WithExecutePrivilege(Privilege.Read)` resolves to `prvReadAccount`. Privilege names use the entity schema name, taken from the early-bound type name (`typeof(T).Name`). A `WithExecutePrivilege(string entitySchemaName, Privilege)` overload is available for late-bound scenarios, and the existing `WithExecutePrivilegeName(string)` remains for non-standard privilege names.