Skip to content
51 changes: 51 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<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.

#### API Design

```csharp
public class SomeCustomApi : Plugin
{
public SomeCustomApi()
{
RegisterAPI<CallbackService>(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<CallbackService>();
}

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:
Expand Down
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<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`.

#### Quick Start

```csharp
public class SomeCustomApi : Plugin
{
public SomeCustomApi()
{
RegisterAPI<CallbackService>(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<CallbackService>();
}

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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tests for the type-safe Custom API analyzers (XPC4004/XPC4005/XPC4006, XPC3001) and their code fixers.
/// </summary>
public class CustomApiHandlerDiagnosticsTests : CodeFixTestBase
{
private const string RegistrationWithParams = """
RegisterAPI<CallbackService>(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<CallbackService>(nameof(SomeApi), nameof(CallbackService.Handle))
.AddRequestParameter("EntityId", CustomApiParameterType.Guid)
.AddResponseProperty("StatusCode", CustomApiParameterType.Integer);
}

protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
=> services.AddScoped<CallbackService>();
}

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<CallbackService>(name, nameof(CallbackService.Handle))
.AddResponseProperty("StatusCode", CustomApiParameterType.Integer);
}

protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
=> services.AddScoped<CallbackService>();
}

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<CallbackService>(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<CallbackService>();
}
}

public class CallbackService
{
{{serviceBody}}
}
}
""";

private static async Task<ImmutableArray<Diagnostic>> GetDiagnosticsAsync(string source, DiagnosticAnalyzer analyzer)
{
var compilation = CompilationHelper.CreateCompilation(source);
var compilationWithAnalyzers = compilation.WithAnalyzers([analyzer]);
return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync();
}
}
Loading
Loading