Skip to content

Commit 95b7f9c

Browse files
mkholtClaude <noreply@anthropic.com> via Conducktor
andauthored
Add type-safe Custom API request/response wrappers (#16)
* Add type-safe Custom API request/response wrappers Mirror the type-safe image feature for Custom APIs. The typed overload RegisterAPI<TService>(name, handlerMethodName) opts in: the source generator emits {ApiName}Request/{ApiName}Response classes (named after the API, in the plugin's namespace) from the AddRequestParameter/AddResponseProperty calls, plus an internal ActionWrapper that marshals InputParameters into the request and the returned response into OutputParameters. The handler signature adapts when no request parameters (no argument) or no response properties (void return) are declared. - Public API/runtime: new typed RegisterAPI overload; CustomApiRegistration carries the handler method; wrapper discovery unified behind PluginStepRegistration.WrapperTypeName (computed at registration time for both plugin steps and Custom APIs). - Source generator: CustomApiGenerator + parser, metadata, type mapper, code generator, and shared helpers. - Diagnostics + fixers: XPC4004 (handler not found), XPC4005/XPC4006 (signature mismatch warning/error), and XPC3001 extended to the Custom API handler arg. - Add XPC3006: warn when typed Custom API name is not a compile-time constant - Generated code is backwards compatible with consumers that have nullable reference types disabled (incl. .NET Framework / C# 7.3): reference-type `?` annotations and the `#nullable enable` directive are emitted only when NRT is enabled; nullable value types are always emitted. This also fixes the image generator emitting `string?` (and CS8669) on NRT-off projects. - Tests: generator output, analyzer/fixer, and end-to-end runtime coverage. - Docs: CHANGELOG, CLAUDE.md, README, and rules/XPC400{4,5,6}.md. --------- Co-authored-by: Claude <noreply@anthropic.com> via Conducktor <conducktor@contextand.com>
1 parent 1b9fb51 commit 95b7f9c

52 files changed

Lines changed: 3475 additions & 102 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,57 @@ All three methods are valid and supported. `WithPreImage` and `WithPostImage` ar
289289
- **Namespace isolation**: Each step gets its own namespace, preventing naming conflicts
290290
- **Shared interfaces**: `IPluginImage`/`IPluginPreImage`/`IPluginPostImage` (and generic variants) let handler methods share logic across the per-registration concrete image types
291291

292+
### Type-Safe Custom API Request/Response
293+
294+
The source generator provides the same compile-time safety for Custom APIs that it provides for plugin images. The typed overload `RegisterAPI<TService>(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.
295+
296+
#### API Design
297+
298+
```csharp
299+
public class SomeCustomApi : Plugin
300+
{
301+
public SomeCustomApi()
302+
{
303+
RegisterAPI<CallbackService>(nameof(SomeCustomApi), nameof(CallbackService.SomeCustomApiMethod))
304+
.AddRequestParameter("EntityLogicalName", CustomApiParameterType.String)
305+
.AddRequestParameter("EntityId", CustomApiParameterType.Guid)
306+
.AddResponseProperty("StatusCode", CustomApiParameterType.Integer)
307+
.AddResponseProperty("ErrorMessage", CustomApiParameterType.String);
308+
}
309+
310+
protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
311+
=> services.AddScoped<CallbackService>();
312+
}
313+
314+
public class CallbackService
315+
{
316+
// Signature is enforced by the source generator (XPC4004/XPC4005/XPC4006)
317+
public SomeCustomApiResponse SomeCustomApiMethod(SomeCustomApiRequest request)
318+
{
319+
var id = request.EntityId; // strongly-typed, from InputParameters["EntityId"]
320+
return new SomeCustomApiResponse(200, string.Empty);
321+
}
322+
}
323+
```
324+
325+
#### How It Works
326+
327+
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.
328+
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?`).
329+
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.
330+
4. **Signature adaptation**: when no request parameters are declared the handler takes no argument; when no response properties are declared it returns `void`.
331+
5. **Runtime execution**: the generated `ActionWrapper` reads `IPluginExecutionContext.InputParameters` into the request, invokes the handler, and writes the returned response's properties into `OutputParameters`.
332+
333+
#### Diagnostics
334+
335+
| Rule | Severity | Meaning |
336+
| --- | --- | --- |
337+
| XPC3006 | Warning | Custom API name must be a compile-time constant (`nameof`/`const`/literal) for generation |
338+
| XPC4004 | Error | Custom API handler method not found on the service type (code fix creates it) |
339+
| XPC4005 | Warning | Handler signature doesn't match the declared parameters, generated types don't exist yet |
340+
| XPC4006 | Error | Handler signature doesn't match, generated types exist (code fix corrects it) |
341+
| XPC3001 | Warning | Prefer `nameof(TService.Method)` over a string literal for the handler argument |
342+
292343
### Dependency Injection
293344

294345
XrmPluginCore supports three patterns for registering custom services:

README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ XrmPluginCore provides base functionality for developing plugins and custom APIs
1313
- **Context Wrappers**: Simplify access to plugin execution context
1414
- **Registration Utilities**: Easily register plugins and custom APIs
1515
- **Type-Safe Images**: Compile-time type safety for PreImages and PostImages via source generators
16+
- **Type-Safe Custom APIs**: Generated request/response classes for Custom APIs via source generators
1617
- **Compatibility**: Supports .NET Framework 4.6.2 and .NET 8
1718

1819
## Usage
@@ -201,9 +202,54 @@ The source generator includes analyzers that help catch common issues at compile
201202
| [XPC3002](XrmPluginCore.SourceGenerator/rules/XPC3002.md) | Info | Consider using modern image registration API |
202203
| [XPC3003](XrmPluginCore.SourceGenerator/rules/XPC3003.md) | Warning | Image registration without method reference |
203204
| [XPC3004](XrmPluginCore.SourceGenerator/rules/XPC3004.md) | Error | Do not use LocalPluginContext as TService in RegisterStep |
205+
| [XPC3006](XrmPluginCore.SourceGenerator/rules/XPC3006.md) | Warning | Custom API name must be a compile-time constant |
204206
| [XPC4001](XrmPluginCore.SourceGenerator/rules/XPC4001.md) | Error | Handler method not found |
205207
| [XPC4002](XrmPluginCore.SourceGenerator/rules/XPC4002.md) | Warning | Handler signature does not match registered images |
206208
| [XPC4003](XrmPluginCore.SourceGenerator/rules/XPC4003.md) | Error | Handler signature does not match registered images |
209+
| [XPC4004](XrmPluginCore.SourceGenerator/rules/XPC4004.md) | Error | Custom API handler method not found |
210+
| [XPC4005](XrmPluginCore.SourceGenerator/rules/XPC4005.md) | Warning | Custom API handler signature does not match registered parameters |
211+
| [XPC4006](XrmPluginCore.SourceGenerator/rules/XPC4006.md) | Error | Custom API handler signature does not match registered parameters |
212+
213+
### Type-Safe Custom APIs
214+
215+
The source generator also creates type-safe request/response classes for Custom APIs. Use the typed overload `RegisterAPI<TService>(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`.
216+
217+
#### Quick Start
218+
219+
```csharp
220+
public class SomeCustomApi : Plugin
221+
{
222+
public SomeCustomApi()
223+
{
224+
RegisterAPI<CallbackService>(nameof(SomeCustomApi), nameof(CallbackService.SomeCustomApiMethod))
225+
.AddRequestParameter("EntityLogicalName", CustomApiParameterType.String)
226+
.AddRequestParameter("EntityId", CustomApiParameterType.Guid)
227+
.AddResponseProperty("StatusCode", CustomApiParameterType.Integer)
228+
.AddResponseProperty("ErrorMessage", CustomApiParameterType.String);
229+
// Source generator validates that SomeCustomApiMethod accepts the request and returns the response
230+
}
231+
232+
protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
233+
=> services.AddScoped<CallbackService>();
234+
}
235+
236+
public class CallbackService
237+
{
238+
public SomeCustomApiResponse SomeCustomApiMethod(SomeCustomApiRequest request)
239+
{
240+
var id = request.EntityId; // strongly-typed, from InputParameters["EntityId"]
241+
return new SomeCustomApiResponse(200, string.Empty);
242+
}
243+
}
244+
```
245+
246+
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.
247+
248+
- **Property names** come from the unique-name argument (e.g. `"EntityId"``EntityId`), which is also the `InputParameters`/`OutputParameters` key.
249+
- **Parameter types** map to CLR types (`String``string`, `Guid``System.Guid`, `Integer``int`, `Money``Money`, …). Optional value-type request parameters become nullable (`int?`).
250+
- **Signature adapts**: with no request parameters the handler takes no argument; with no response properties it returns `void`.
251+
252+
The handler signature is enforced by analyzers (XPC4004/XPC4005/XPC4006), each with a code fix.
207253

208254
### Using the LocalPluginContext wrapper (Legacy)
209255

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
using FluentAssertions;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.Diagnostics;
4+
using System.Collections.Immutable;
5+
using XrmPluginCore.SourceGenerator.Analyzers;
6+
using XrmPluginCore.SourceGenerator.CodeFixes;
7+
using XrmPluginCore.SourceGenerator.Tests.Helpers;
8+
using Xunit;
9+
10+
namespace XrmPluginCore.SourceGenerator.Tests.DiagnosticTests;
11+
12+
/// <summary>
13+
/// Tests for the type-safe Custom API analyzers (XPC4004/XPC4005/XPC4006, XPC3001) and their code fixers.
14+
/// </summary>
15+
public class CustomApiHandlerDiagnosticsTests : CodeFixTestBase
16+
{
17+
private const string RegistrationWithParams = """
18+
RegisterAPI<CallbackService>(nameof(SomeApi), nameof(CallbackService.Handle))
19+
.AddRequestParameter("EntityId", CustomApiParameterType.Guid)
20+
.AddResponseProperty("StatusCode", CustomApiParameterType.Integer);
21+
""";
22+
23+
[Fact]
24+
public async Task Should_Report_XPC4004_When_Handler_Method_Missing()
25+
{
26+
var source = WrapPlugin(RegistrationWithParams, serviceBody: "// no methods");
27+
28+
var diagnostics = await GetDiagnosticsAsync(source, new CustomApiHandlerMethodNotFoundAnalyzer());
29+
30+
diagnostics.Should().ContainSingle(d => d.Id == "XPC4004");
31+
diagnostics.Single(d => d.Id == "XPC4004").Severity.Should().Be(DiagnosticSeverity.Error);
32+
}
33+
34+
[Fact]
35+
public async Task Should_Report_XPC4005_When_Signature_Wrong_And_Types_Missing()
36+
{
37+
// Handler exists but has the wrong signature; the generated request/response types do not exist.
38+
var source = WrapPlugin(RegistrationWithParams, serviceBody: "public void Handle() { }");
39+
40+
var diagnostics = await GetDiagnosticsAsync(source, new CustomApiHandlerSignatureMismatchAnalyzer());
41+
42+
var diagnostic = diagnostics.Should().ContainSingle(d => d.Id == "XPC4005").Subject;
43+
diagnostic.Severity.Should().Be(DiagnosticSeverity.Warning);
44+
diagnostic.GetMessage().Should().Contain("SomeApiResponse Handle(SomeApiRequest request)");
45+
}
46+
47+
[Fact]
48+
public async Task Should_Report_XPC4006_When_Signature_Wrong_And_Types_Exist()
49+
{
50+
// The generated types exist in the compilation, so the mismatch escalates to an error.
51+
var source = WrapPlugin(RegistrationWithParams, serviceBody: "public void Handle() { }")
52+
+ GeneratedTypes;
53+
54+
var diagnostics = await GetDiagnosticsAsync(source, new CustomApiHandlerSignatureMismatchAnalyzer());
55+
56+
diagnostics.Should().ContainSingle(d => d.Id == "XPC4006")
57+
.Which.Severity.Should().Be(DiagnosticSeverity.Error);
58+
}
59+
60+
[Fact]
61+
public async Task Should_Not_Report_When_Signature_Matches()
62+
{
63+
var source = WrapPlugin(RegistrationWithParams, serviceBody: "public SomeApiResponse Handle(SomeApiRequest request) => new SomeApiResponse(0);")
64+
+ GeneratedTypes;
65+
66+
var diagnostics = await GetDiagnosticsAsync(source, new CustomApiHandlerSignatureMismatchAnalyzer());
67+
68+
diagnostics.Should().NotContain(d => d.Id == "XPC4005" || d.Id == "XPC4006");
69+
}
70+
71+
[Fact]
72+
public async Task Should_Report_Mismatch_When_Handler_Uses_Same_Named_Type_From_Different_Namespace()
73+
{
74+
// The handler's request/response types share the generated types' short names but live in a
75+
// different namespace, so they are NOT the generated types and must be reported as a mismatch.
76+
const string source = """
77+
using XrmPluginCore;
78+
using XrmPluginCore.Enums;
79+
using Microsoft.Extensions.DependencyInjection;
80+
81+
namespace Other
82+
{
83+
public sealed class SomeApiRequest { }
84+
public sealed class SomeApiResponse { }
85+
}
86+
87+
namespace TestNamespace
88+
{
89+
public class SomeApi : Plugin
90+
{
91+
public SomeApi()
92+
{
93+
RegisterAPI<CallbackService>(nameof(SomeApi), nameof(CallbackService.Handle))
94+
.AddRequestParameter("EntityId", CustomApiParameterType.Guid)
95+
.AddResponseProperty("StatusCode", CustomApiParameterType.Integer);
96+
}
97+
98+
protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
99+
=> services.AddScoped<CallbackService>();
100+
}
101+
102+
public class CallbackService
103+
{
104+
public Other.SomeApiResponse Handle(Other.SomeApiRequest request) => new Other.SomeApiResponse();
105+
}
106+
}
107+
""";
108+
109+
var diagnostics = await GetDiagnosticsAsync(source, new CustomApiHandlerSignatureMismatchAnalyzer());
110+
111+
diagnostics.Should().Contain(d => d.Id == "XPC4005" || d.Id == "XPC4006");
112+
}
113+
114+
[Fact]
115+
public async Task Should_Report_XPC3006_When_Api_Name_Is_Not_Constant()
116+
{
117+
const string source = """
118+
using XrmPluginCore;
119+
using XrmPluginCore.Enums;
120+
using Microsoft.Extensions.DependencyInjection;
121+
122+
namespace TestNamespace
123+
{
124+
public class SomeApi : Plugin
125+
{
126+
public SomeApi()
127+
{
128+
var name = System.Guid.NewGuid().ToString();
129+
RegisterAPI<CallbackService>(name, nameof(CallbackService.Handle))
130+
.AddResponseProperty("StatusCode", CustomApiParameterType.Integer);
131+
}
132+
133+
protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
134+
=> services.AddScoped<CallbackService>();
135+
}
136+
137+
public class CallbackService
138+
{
139+
public object Handle() => null;
140+
}
141+
}
142+
""";
143+
144+
var diagnostics = await GetDiagnosticsAsync(source, new CustomApiNameNotConstantAnalyzer());
145+
146+
diagnostics.Should().ContainSingle(d => d.Id == "XPC3006")
147+
.Which.Severity.Should().Be(DiagnosticSeverity.Warning);
148+
}
149+
150+
[Fact]
151+
public async Task Should_Not_Report_XPC3006_When_Api_Name_Is_Nameof()
152+
{
153+
var source = WrapPlugin(RegistrationWithParams, serviceBody: "public SomeApiResponse Handle(SomeApiRequest request) => new SomeApiResponse(0);")
154+
+ GeneratedTypes;
155+
156+
var diagnostics = await GetDiagnosticsAsync(source, new CustomApiNameNotConstantAnalyzer());
157+
158+
diagnostics.Should().NotContain(d => d.Id == "XPC3006");
159+
}
160+
161+
[Fact]
162+
public async Task Should_Report_XPC3001_For_String_Literal_Handler()
163+
{
164+
const string registration = """
165+
RegisterAPI<CallbackService>(nameof(SomeApi), "Handle")
166+
.AddResponseProperty("StatusCode", CustomApiParameterType.Integer);
167+
""";
168+
var source = WrapPlugin(registration, serviceBody: "public SomeApiResponse Handle() => new SomeApiResponse(0);")
169+
+ GeneratedTypes;
170+
171+
var diagnostics = await GetDiagnosticsAsync(source, new PreferNameofAnalyzer());
172+
173+
var diagnostic = diagnostics.Should().ContainSingle(d => d.Id == "XPC3001").Subject;
174+
diagnostic.Properties["ServiceType"].Should().Be("CallbackService");
175+
diagnostic.Properties["MethodName"].Should().Be("Handle");
176+
}
177+
178+
[Fact]
179+
public async Task Should_Fix_Missing_Handler_Method()
180+
{
181+
var source = WrapPlugin(RegistrationWithParams, serviceBody: "// no methods");
182+
183+
var fixedSource = await ApplyCodeFixAsync(
184+
source,
185+
new CustomApiHandlerMethodNotFoundAnalyzer(),
186+
new CreateCustomApiHandlerMethodCodeFixProvider(),
187+
DiagnosticDescriptors.CustomApiHandlerMethodNotFound.Id);
188+
189+
fixedSource.Should().Contain("TestNamespace.SomeApiResponse Handle(TestNamespace.SomeApiRequest request)");
190+
}
191+
192+
[Fact]
193+
public async Task Should_Fix_Wrong_Handler_Signature()
194+
{
195+
var source = WrapPlugin(RegistrationWithParams, serviceBody: "public void Handle() { }")
196+
+ GeneratedTypes;
197+
198+
var fixedSource = await ApplyCodeFixAsync(
199+
source,
200+
new CustomApiHandlerSignatureMismatchAnalyzer(),
201+
new FixCustomApiHandlerSignatureCodeFixProvider(),
202+
DiagnosticDescriptors.CustomApiHandlerSignatureMismatch.Id,
203+
DiagnosticDescriptors.CustomApiHandlerSignatureMismatchError.Id);
204+
205+
fixedSource.Should().Contain("Handle(TestNamespace.SomeApiRequest request)");
206+
fixedSource.Should().Contain("TestNamespace.SomeApiResponse Handle");
207+
}
208+
209+
private const string GeneratedTypes = """
210+
211+
212+
namespace TestNamespace
213+
{
214+
public sealed class SomeApiRequest { public System.Guid EntityId { get; set; } }
215+
public sealed class SomeApiResponse
216+
{
217+
public int StatusCode { get; set; }
218+
public SomeApiResponse(int statusCode) { StatusCode = statusCode; }
219+
}
220+
}
221+
""";
222+
223+
private static string WrapPlugin(string registration, string serviceBody) =>
224+
$$"""
225+
using System;
226+
using XrmPluginCore;
227+
using XrmPluginCore.Enums;
228+
using Microsoft.Extensions.DependencyInjection;
229+
230+
namespace TestNamespace
231+
{
232+
public class SomeApi : Plugin
233+
{
234+
public SomeApi()
235+
{
236+
{{registration}}
237+
}
238+
239+
protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
240+
{
241+
return services.AddScoped<CallbackService>();
242+
}
243+
}
244+
245+
public class CallbackService
246+
{
247+
{{serviceBody}}
248+
}
249+
}
250+
""";
251+
252+
private static async Task<ImmutableArray<Diagnostic>> GetDiagnosticsAsync(string source, DiagnosticAnalyzer analyzer)
253+
{
254+
var compilation = CompilationHelper.CreateCompilation(source);
255+
var compilationWithAnalyzers = compilation.WithAnalyzers([analyzer]);
256+
return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync();
257+
}
258+
}

0 commit comments

Comments
 (0)