Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,9 @@ dvx plugin adopt --project <path> [options]
attribute (adding `using dvx.PluginAttributes;` where needed).
4. Skips classes that already carry an equivalent attribute, and reports any Dataverse step whose
class it could not find in the project.
5. Detects **Custom API** registrations (their Main Operation steps, and steps on Custom API
messages) and, instead of writing `[PluginStep]`, marks the implementing class with `[CustomApi]`
so `sync` / `register` skip it. See [Custom API implementations](#custom-api-implementations).

> `adopt` never writes to Dataverse — it only edits source files. Review the result with `git diff`,
> then run `dvx plugin sync` to bring Dataverse under attribute control.
Expand Down Expand Up @@ -769,6 +772,23 @@ Each time `register` or `sync` runs, dvx performs a full sync for the target ass
If dvx finds a class that implements `IPlugin` but has no `[PluginStep]` attribute, it
logs a **warning** and skips that class. All other steps are still processed.

### Custom API implementations

Custom APIs are **not** event plugins — their code runs at the Main Operation stage, bound through
the `customapi` record rather than an SDK message processing step. Mark a Custom API's
implementation class with `[CustomApi]` and dvx's discovery skips it **silently** (no
"missing `[PluginStep]`" warning), so `sync` / `register` never try to manage it as a step:

```csharp
using dvx.PluginAttributes;

[CustomApi]
public class MyCustomApi : IPlugin { ... }
```

`adopt` applies this for you: any Custom API step it finds in Dataverse is skipped and its class is
marked `[CustomApi]` instead of `[PluginStep]`.

### Solution membership

When `--solution-unique-name` (or `solutionUniqueName` in config) is set, dvx adds each
Expand Down Expand Up @@ -878,6 +898,7 @@ dvx reads and writes the following Dataverse tables:
| `pluginpackage` | Stores the plugin package (nupkg) in its `content` column. Queried by `uniquename`, then updated with the new `.nupkg` content on deploy. |
| `pluginassembly` | Child record created by Dataverse when it processes a plugin package. Queried after deploy to get the ID for step registration. Also queried by `--assembly-name` to download content bytes. |
| `plugintype` | One record per plugin class. Queried to resolve class names to GUIDs for step registration. |
| `customapi` | Queried by `adopt` to identify Custom API registrations (by `plugintypeid` / `sdkmessageid`) so their steps are skipped rather than scaffolded as `[PluginStep]`. |
| `sdkmessage` | Lookup table for message names (`Create`, `Update`, `Delete`, …). Loaded once and cached per run. |
| `sdkmessagefilter` | Associates messages with entity types and indicates whether custom steps are allowed. |
| `sdkmessageprocessingstep` | The step registration itself. Created, updated, and deleted by dvx. |
Expand All @@ -895,6 +916,7 @@ PluginRegistrationTool/
├── src/
│ ├── dvx.PluginAttributes/ # netstandard2.0 NuGet package
│ │ ├── PluginStepAttribute.cs # [PluginStep] attribute with all config
│ │ ├── CustomApiAttribute.cs # [CustomApi] marker — excludes Custom API impls from discovery
│ │ ├── Stage.cs # PreValidation / PreOperation / PostOperation
│ │ └── dvx.PluginAttributes.csproj
│ ├── dvx/ # net8 CLI tool
Expand Down
4 changes: 4 additions & 0 deletions RegressionTests.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ Run against a real Dataverse environment. Mark each test **PASS / FAIL / SKIP**
| SYN-11 | Entity+message not filterable | Use a combination that has no `sdkmessagefilter` record in Dataverse (with an `Entity` set). | Warning: "No sdkmessagefilter for entity … + message …". Step skipped. |
| SYN-14 | Entity-less (global) message | Register `[PluginStep(Message = "Associate", Stage = Stage.PostOperation)]` (no `Entity`). Run `sync`. | Step **created** with `sdkmessagefilterid` empty. No "No sdkmessagefilter" warning. Verify in PRT the step targets the Associate message with no primary entity. |
| SYN-13 | Partial failure — some steps fail | Simulate by revoking write access to one entity. | Exit 2. Successful steps show in Created/Updated counts. Failed step shown in Errors. |
| SYN-15 | `[CustomApi]` class skipped silently | Add a class implementing `IPlugin` marked `[CustomApi]` (a Custom API implementation, no `[PluginStep]`). Run `sync` (or `register`). | The `[CustomApi]` class is skipped with **no** "no `[PluginStep]`" warning (contrast SYN-07). All other steps processed normally. Exit 0. |

---

Expand All @@ -119,6 +120,9 @@ Run against a real Dataverse environment. Mark each test **PASS / FAIL / SKIP**
| ADO-09 | Assembly-name override | Run `adopt --assembly-name <name>` where the Dataverse assembly name differs from the project name. | Tool reads steps from the named assembly. |
| ADO-10 | Assembly not found | Run `adopt --assembly-name does-not-exist`. | Exit 1. Error message names the missing assembly. |
| ADO-11 | Entity-less (global) message adopted | Adopt an assembly with a hand-registered `Associate` / `Disassociate` step (no `sdkmessagefilter`). | Generated attribute has an empty entity, e.g. `[PluginStep("", "Associate", Stage.PostOperation)]`. **No** "no entity / not representable" warning. Step is adopted, not skipped. |
| ADO-12 | Custom API not scaffolded; class marked `[CustomApi]` | Adopt an assembly that backs a **Custom API** (plugin bound via `customapi.plugintypeid`; its step is registered at stage 30 / Main Operation). | **No** `[PluginStep]` is written for the Custom API class; the class is marked `[CustomApi]` instead (with `using dvx.PluginAttributes;`). Summary reports `N Custom API class(es) marked [CustomApi]`. Standard event-plugin steps in the same assembly are still adopted. Exit 0. |
| ADO-13 | Custom API dry run | Run the ADO-12 scenario with `--dry-run`. | Planned output shows `… ← [CustomApi]` for the Custom API class and **no** stage-30 `[PluginStep]` / `(Stage)30`. No source files modified (`git status` clean). |
| ADO-14 | Custom API marker idempotent | Run `adopt` twice against the Custom API assembly. | Second run does not add a duplicate `[CustomApi]` (reported as already present / skipped). No further file edits. |

---

Expand Down
15 changes: 15 additions & 0 deletions src/dvx.PluginAttributes/CustomApiAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System;

namespace dvx.PluginAttributes
{
/// <summary>
/// Marks an IPlugin class as the implementation of a Custom API rather than a standard event
/// plugin. dvx's reflection discovery (used by <c>sync</c>/<c>register</c>) skips classes marked
/// with this attribute, and <c>adopt</c> writes it instead of <see cref="PluginStepAttribute"/>
/// for Custom API implementations it finds in Dataverse.
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class CustomApiAttribute : Attribute
{
}
}
2 changes: 1 addition & 1 deletion src/dvx.PluginAttributes/dvx.PluginAttributes.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PackageId>dvx.PluginAttributes</PackageId>
<Version>1.1.2</Version>
<Version>1.2.0</Version>
<Authors>Byron Matus</Authors>
<Description>Attributes for declarative Dataverse plugin step registration using the dvx cli</Description>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
Expand Down
44 changes: 42 additions & 2 deletions src/dvx.Tests/AttributeWriterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ private static PluginStepDefinition Def(
private static (AttributeWriteResult Result, Dictionary<string, string> Files) Run(
Dictionary<string, string> sources,
IReadOnlyList<PluginStepDefinition> defs,
bool dryRun = false)
bool dryRun = false,
IReadOnlyCollection<string>? customApiTypeNames = null)
{
var dir = Path.Combine(Path.GetTempPath(), "dvx-aw-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
Expand All @@ -31,7 +32,8 @@ private static (AttributeWriteResult Result, Dictionary<string, string> Files) R
File.WriteAllText(Path.Combine(dir, name), content);

var writer = new AttributeWriter(NullLogger<AttributeWriter>.Instance);
var result = writer.Write(Path.Combine(dir, "Test.csproj"), defs, dryRun);
var result = writer.Write(Path.Combine(dir, "Test.csproj"), defs, dryRun,
customApiTypeNames: customApiTypeNames);

var outFiles = sources.Keys.ToDictionary(
n => n,
Expand Down Expand Up @@ -149,6 +151,44 @@ public void DryRun_ReportsButDoesNotWrite()
files["Account.cs"].ShouldBe(SimpleClass); // unchanged on disk
}

// ── [CustomApi] markers ────────────────────────────────────────────────────

[Fact]
public void MarksCustomApiClass_WithAttributeAndUsing()
{
var (result, files) = Run(
new() { ["Account.cs"] = SimpleClass },
Array.Empty<PluginStepDefinition>(),
customApiTypeNames: new[] { "My.Plugins.AccountCreate" });

result.CustomApisMarked.ShouldBe(1);
result.FilesChanged.ShouldHaveSingleItem();

var text = files["Account.cs"];
text.ShouldContain("using Microsoft.Xrm.Sdk;\nusing dvx.PluginAttributes;\n");
text.ShouldContain(" [CustomApi]\n public class AccountCreate");
}

[Fact]
public void CustomApiMarker_Idempotent_WhenAlreadyPresent()
{
var src =
"using Microsoft.Xrm.Sdk;\nusing dvx.PluginAttributes;\n\n" +
"namespace My.Plugins\n{\n" +
" [CustomApi]\n" +
" public class AccountCreate : IPlugin\n {\n" +
" public void Execute(IServiceProvider sp) { }\n }\n}\n";

var (result, files) = Run(
new() { ["Account.cs"] = src },
Array.Empty<PluginStepDefinition>(),
customApiTypeNames: new[] { "My.Plugins.AccountCreate" });

result.CustomApisMarked.ShouldBe(0);
result.FilesChanged.ShouldBeEmpty();
files["Account.cs"].ShouldBe(src.Replace("\r\n", "\n"));
}

// ── RenderAttribute ──────────────────────────────────────────────────────

[Fact]
Expand Down
31 changes: 31 additions & 0 deletions src/dvx.Tests/PluginDiscoveryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,21 @@ public class TestPluginConfiguration : IPlugin
{
public void Execute(IServiceProvider serviceProvider) { }
}

// Custom API implementation — marked [CustomApi], no [PluginStep]. Must be skipped silently.
[CustomApi]
public class TestPluginCustomApi : IPlugin
{
public void Execute(IServiceProvider serviceProvider) { }
}

// [CustomApi] takes precedence over [PluginStep] on the same class — must still be excluded.
[CustomApi]
[PluginStep("account", "Create", Stage.PostOperation)]
public class TestPluginCustomApiWithStep : IPlugin
{
public void Execute(IServiceProvider serviceProvider) { }
}
}

namespace dvx.Tests
Expand Down Expand Up @@ -425,5 +440,21 @@ public void NonIPluginClass_ExcludedFromResults()
var defs = Discover();
defs.ShouldNotContain(d => d.TypeFullName!.EndsWith(nameof(NotAPlugin)));
}

[Fact]
public void CustomApiClass_ExcludedFromResults()
{
var defs = Discover();
defs.ShouldNotContain(d => d.TypeFullName!.EndsWith(nameof(TestPluginCustomApi)));
}

[Fact]
public void CustomApiClass_WithPluginStep_ExcludedFromResults()
{
// [CustomApi] marks the class as a Custom API implementation, so it must be skipped
// even when a [PluginStep] attribute is also present.
var defs = Discover();
defs.ShouldNotContain(d => d.TypeFullName!.EndsWith(nameof(TestPluginCustomApiWithStep)));
}
}
}
55 changes: 54 additions & 1 deletion src/dvx.Tests/StepImporterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,14 @@ private static IOrganizationService BuildSvc(
Entity? step = null,
EntityCollection? images = null,
string entity = "account",
string message = "Create")
string message = "Create",
EntityCollection? customApis = null)
{
var svc = Substitute.For<IOrganizationService>();

svc.RetrieveMultiple(Arg.Is<QueryExpression>(q => q.EntityName == "customapi"))
.Returns(customApis ?? new EntityCollection());

svc.RetrieveMultiple(Arg.Is<QueryExpression>(q => q.EntityName == "plugintype"))
.Returns(new EntityCollection(new List<Entity>
{
Expand Down Expand Up @@ -155,6 +159,55 @@ public void Import_GlobalMessageStep_AdoptsWithEmptyEntity()
result.Warnings.ShouldBeEmpty();
}

[Fact]
public void Import_MainOperationStep_Stage30_SkippedAsCustomApi()
{
// Custom API plugins register at stage 30 (Main Operation) — never an event plugin.
var step = FullStep();
step["stage"] = new OptionSetValue(30);

var result = Importer(BuildSvc(step: step)).Import(AssemblyId);

result.Definitions.ShouldBeEmpty();
result.CustomApiTypes.ShouldContain(TypeName);
}

[Fact]
public void Import_StepOnCustomApiMessage_Skipped()
{
// A step whose message is a Custom API message is not an event plugin step.
var customApis = new EntityCollection(new List<Entity>
{
new("customapi", Guid.NewGuid())
{
["sdkmessageid"] = new EntityReference("sdkmessage", MsgId)
}
});

var result = Importer(BuildSvc(step: FullStep(), customApis: customApis)).Import(AssemblyId);

result.Definitions.ShouldBeEmpty();
result.CustomApiTypes.ShouldContain(TypeName);
}

[Fact]
public void Import_StepForCustomApiPluginType_Skipped()
{
// A step whose plugin type backs a Custom API (customapi.plugintypeid) is skipped.
var customApis = new EntityCollection(new List<Entity>
{
new("customapi", Guid.NewGuid())
{
["plugintypeid"] = new EntityReference("plugintype", PluginTypeId)
}
});

var result = Importer(BuildSvc(step: FullStep(), customApis: customApis)).Import(AssemblyId);

result.Definitions.ShouldBeEmpty();
result.CustomApiTypes.ShouldContain(TypeName);
}

[Fact]
public void Import_SystemImpersonation_MapsToGuidEmpty()
{
Expand Down
8 changes: 6 additions & 2 deletions src/dvx/Commands/AdoptCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,17 @@ public static Command Build(ILoggerFactory loggerFactory)
var importer = new StepImporter(svc, loggerFactory.CreateLogger<StepImporter>());
var importResult = importer.Import(assemblyId, isVerbose);
Out.Info($"Found {importResult.Definitions.Count} step(s) to adopt.");
if (importResult.CustomApiTypes.Count > 0)
Out.Info($"Skipping {importResult.CustomApiTypes.Count} Custom API implementation(s) — will mark [CustomApi].");
foreach (var w in importResult.Warnings) Out.Warn(w);

if (isDryRun)
Out.DryRun("— no files will be modified.");

Out.Step("Writing", $"[PluginStep] attributes into {Path.GetFileName(resolvedProject)}");
var writer = new AttributeWriter(loggerFactory.CreateLogger<AttributeWriter>());
var writeResult = writer.Write(resolvedProject, importResult.Definitions, isDryRun, isVerbose);
var writeResult = writer.Write(
resolvedProject, importResult.Definitions, isDryRun, isVerbose, importResult.CustomApiTypes);

foreach (var line in writeResult.Planned) Out.SubStep(line);
foreach (var t in writeResult.UnmatchedTypes) Out.Warn($"No source class found for '{t}' — skipped.");
Expand All @@ -80,7 +83,8 @@ public static Command Build(ILoggerFactory loggerFactory)
Out.Info("");
Out.Success(isDryRun ? "Would adopt:" : "Adopted:",
$"{writeResult.Added} attribute(s) across {writeResult.FilesChanged.Count} file(s); " +
$"{writeResult.SkippedExisting} already present.");
$"{writeResult.SkippedExisting} already present; " +
$"{writeResult.CustomApisMarked} Custom API class(es) marked [CustomApi].");

if (!isDryRun && writeResult.Added > 0)
Out.Info("Next: review the changes (git diff), then run 'dvx sync' " +
Expand Down
3 changes: 3 additions & 0 deletions src/dvx/Models/AttributeWriteResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ public class AttributeWriteResult
/// <summary>Number of steps skipped because an equivalent attribute already existed.</summary>
public int SkippedExisting { get; set; }

/// <summary>Number of classes marked with [CustomApi] (Custom API implementations).</summary>
public int CustomApisMarked { get; set; }

/// <summary>Source files that were (or would be) modified, relative to the project dir.</summary>
public List<string> FilesChanged { get; } = new();

Expand Down
5 changes: 5 additions & 0 deletions src/dvx/Models/ImportResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,10 @@ public class ImportResult
{
public List<PluginStepDefinition> Definitions { get; } = new();
public List<string> Warnings { get; } = new();

/// <summary>
/// Full type names of plugin classes that back a Custom API
/// </summary>
public List<string> CustomApiTypes { get; } = new();
}
}
Loading
Loading