From e26b720a59c861324c211c3567d68c9e64618330 Mon Sep 17 00:00:00 2001 From: Byron Matus Date: Thu, 18 Jun 2026 15:04:05 +0200 Subject: [PATCH] Add support to ignore and decorate Custom API implementations --- README.md | 22 ++++++++ RegressionTests.md | 4 ++ .../CustomApiAttribute.cs | 15 +++++ .../dvx.PluginAttributes.csproj | 2 +- src/dvx.Tests/AttributeWriterTests.cs | 44 ++++++++++++++- src/dvx.Tests/PluginDiscoveryTests.cs | 31 +++++++++++ src/dvx.Tests/StepImporterTests.cs | 55 ++++++++++++++++++- src/dvx/Commands/AdoptCommand.cs | 8 ++- src/dvx/Models/AttributeWriteResult.cs | 3 + src/dvx/Models/ImportResult.cs | 5 ++ src/dvx/Services/AttributeWriter.cs | 37 ++++++++++++- src/dvx/Services/PluginDiscovery.cs | 22 ++++++++ src/dvx/Services/SdkMetadata.cs | 31 +++++++++++ src/dvx/Services/StepImporter.cs | 28 +++++++++- src/dvx/dvx.csproj | 4 +- 15 files changed, 301 insertions(+), 10 deletions(-) create mode 100644 src/dvx.PluginAttributes/CustomApiAttribute.cs diff --git a/README.md b/README.md index 2255d9a..e117dd7 100644 --- a/README.md +++ b/README.md @@ -478,6 +478,9 @@ dvx plugin adopt --project [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. @@ -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 @@ -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. | @@ -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 diff --git a/RegressionTests.md b/RegressionTests.md index 41e5a5a..6f56a6f 100644 --- a/RegressionTests.md +++ b/RegressionTests.md @@ -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. | --- @@ -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 ` 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. | --- diff --git a/src/dvx.PluginAttributes/CustomApiAttribute.cs b/src/dvx.PluginAttributes/CustomApiAttribute.cs new file mode 100644 index 0000000..3744ea5 --- /dev/null +++ b/src/dvx.PluginAttributes/CustomApiAttribute.cs @@ -0,0 +1,15 @@ +using System; + +namespace dvx.PluginAttributes +{ + /// + /// Marks an IPlugin class as the implementation of a Custom API rather than a standard event + /// plugin. dvx's reflection discovery (used by sync/register) skips classes marked + /// with this attribute, and adopt writes it instead of + /// for Custom API implementations it finds in Dataverse. + /// + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class CustomApiAttribute : Attribute + { + } +} diff --git a/src/dvx.PluginAttributes/dvx.PluginAttributes.csproj b/src/dvx.PluginAttributes/dvx.PluginAttributes.csproj index 53d4c45..6bfba93 100644 --- a/src/dvx.PluginAttributes/dvx.PluginAttributes.csproj +++ b/src/dvx.PluginAttributes/dvx.PluginAttributes.csproj @@ -1,7 +1,7 @@  dvx.PluginAttributes - 1.1.2 + 1.2.0 Byron Matus Attributes for declarative Dataverse plugin step registration using the dvx cli true diff --git a/src/dvx.Tests/AttributeWriterTests.cs b/src/dvx.Tests/AttributeWriterTests.cs index 0f7be43..3fd6d21 100644 --- a/src/dvx.Tests/AttributeWriterTests.cs +++ b/src/dvx.Tests/AttributeWriterTests.cs @@ -21,7 +21,8 @@ private static PluginStepDefinition Def( private static (AttributeWriteResult Result, Dictionary Files) Run( Dictionary sources, IReadOnlyList defs, - bool dryRun = false) + bool dryRun = false, + IReadOnlyCollection? customApiTypeNames = null) { var dir = Path.Combine(Path.GetTempPath(), "dvx-aw-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); @@ -31,7 +32,8 @@ private static (AttributeWriteResult Result, Dictionary Files) R File.WriteAllText(Path.Combine(dir, name), content); var writer = new AttributeWriter(NullLogger.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, @@ -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(), + 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(), + customApiTypeNames: new[] { "My.Plugins.AccountCreate" }); + + result.CustomApisMarked.ShouldBe(0); + result.FilesChanged.ShouldBeEmpty(); + files["Account.cs"].ShouldBe(src.Replace("\r\n", "\n")); + } + // ── RenderAttribute ────────────────────────────────────────────────────── [Fact] diff --git a/src/dvx.Tests/PluginDiscoveryTests.cs b/src/dvx.Tests/PluginDiscoveryTests.cs index ab40551..c2faa0f 100644 --- a/src/dvx.Tests/PluginDiscoveryTests.cs +++ b/src/dvx.Tests/PluginDiscoveryTests.cs @@ -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 @@ -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))); + } } } diff --git a/src/dvx.Tests/StepImporterTests.cs b/src/dvx.Tests/StepImporterTests.cs index ca29654..59310e9 100644 --- a/src/dvx.Tests/StepImporterTests.cs +++ b/src/dvx.Tests/StepImporterTests.cs @@ -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(); + svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "customapi")) + .Returns(customApis ?? new EntityCollection()); + svc.RetrieveMultiple(Arg.Is(q => q.EntityName == "plugintype")) .Returns(new EntityCollection(new List { @@ -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 + { + 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 + { + 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() { diff --git a/src/dvx/Commands/AdoptCommand.cs b/src/dvx/Commands/AdoptCommand.cs index 80284c2..f4a9f55 100644 --- a/src/dvx/Commands/AdoptCommand.cs +++ b/src/dvx/Commands/AdoptCommand.cs @@ -64,6 +64,8 @@ public static Command Build(ILoggerFactory loggerFactory) var importer = new StepImporter(svc, loggerFactory.CreateLogger()); 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) @@ -71,7 +73,8 @@ public static Command Build(ILoggerFactory loggerFactory) Out.Step("Writing", $"[PluginStep] attributes into {Path.GetFileName(resolvedProject)}"); var writer = new AttributeWriter(loggerFactory.CreateLogger()); - 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."); @@ -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' " + diff --git a/src/dvx/Models/AttributeWriteResult.cs b/src/dvx/Models/AttributeWriteResult.cs index c14062c..b683f76 100644 --- a/src/dvx/Models/AttributeWriteResult.cs +++ b/src/dvx/Models/AttributeWriteResult.cs @@ -11,6 +11,9 @@ public class AttributeWriteResult /// Number of steps skipped because an equivalent attribute already existed. public int SkippedExisting { get; set; } + /// Number of classes marked with [CustomApi] (Custom API implementations). + public int CustomApisMarked { get; set; } + /// Source files that were (or would be) modified, relative to the project dir. public List FilesChanged { get; } = new(); diff --git a/src/dvx/Models/ImportResult.cs b/src/dvx/Models/ImportResult.cs index dcb7a2e..428647f 100644 --- a/src/dvx/Models/ImportResult.cs +++ b/src/dvx/Models/ImportResult.cs @@ -8,5 +8,10 @@ public class ImportResult { public List Definitions { get; } = new(); public List Warnings { get; } = new(); + + /// + /// Full type names of plugin classes that back a Custom API + /// + public List CustomApiTypes { get; } = new(); } } diff --git a/src/dvx/Services/AttributeWriter.cs b/src/dvx/Services/AttributeWriter.cs index c286ab9..a62d5da 100644 --- a/src/dvx/Services/AttributeWriter.cs +++ b/src/dvx/Services/AttributeWriter.cs @@ -22,7 +22,8 @@ public AttributeWriteResult Write( string projectPath, IReadOnlyList definitions, bool dryRun, - bool verbose = false) + bool verbose = false, + IReadOnlyCollection? customApiTypeNames = null) { var result = new AttributeWriteResult(); var projectDir = Path.GetDirectoryName(Path.GetFullPath(projectPath))!; @@ -93,6 +94,30 @@ public AttributeWriteResult Write( AddEdit(edits, file, new Insertion(target.SpanStart, insert, IsUsing: false)); } + // ── Phase B1: [CustomApi] markers for Custom API implementation classes ──── + foreach (var typeName in (customApiTypeNames ?? Array.Empty()) + .Distinct(StringComparer.Ordinal)) + { + if (!classIndex.TryGetValue(typeName, out var locations)) + { + result.UnmatchedTypes.Add(typeName); + continue; + } + + if (locations.Any(HasCustomApiAttribute)) + continue; // already marked — idempotent + + var target = ChooseTarget(locations); + var file = target.SyntaxTree.FilePath; + var text = fileText[file]; + var nl = NewLine(text); + var indent = LineIndent(text, target.SpanStart); + + AddEdit(edits, file, new Insertion(target.SpanStart, $"[CustomApi]{nl}{indent}", IsUsing: false)); + result.CustomApisMarked++; + result.Planned.Add($"{Rel(file, projectDir)}: {typeName} ← [CustomApi]"); + } + // ── Phase B2: ensure the using directive in each touched file ────────────── foreach (var file in edits.Keys.ToList()) { @@ -198,6 +223,16 @@ name is "PluginStep" or "PluginStepAttribute" || name.EndsWith(".PluginStep", StringComparison.Ordinal) || name.EndsWith(".PluginStepAttribute", StringComparison.Ordinal); + private static bool HasCustomApiAttribute(ClassDeclarationSyntax cls) + => cls.AttributeLists + .SelectMany(l => l.Attributes) + .Any(a => IsCustomApiName(a.Name.ToString())); + + private static bool IsCustomApiName(string name) => + name is "CustomApi" or "CustomApiAttribute" || + name.EndsWith(".CustomApi", StringComparison.Ordinal) || + name.EndsWith(".CustomApiAttribute", StringComparison.Ordinal); + private static (string entity, string message, int stage)? ReadStepKey(AttributeSyntax attr) { string? entity = null, message = null; diff --git a/src/dvx/Services/PluginDiscovery.cs b/src/dvx/Services/PluginDiscovery.cs index 80d7457..c2542da 100644 --- a/src/dvx/Services/PluginDiscovery.cs +++ b/src/dvx/Services/PluginDiscovery.cs @@ -11,6 +11,7 @@ public class PluginDiscovery(ILogger logger) { private const string PluginInterfaceFullName = "Microsoft.Xrm.Sdk.IPlugin"; private const string PluginStepAttrFullName = "dvx.PluginAttributes.PluginStepAttribute"; + private const string CustomApiAttrFullName = "dvx.PluginAttributes.CustomApiAttribute"; public List Discover(string dllPath, bool verbose = false) { @@ -49,6 +50,23 @@ public List Discover(string dllPath, bool verbose = false) continue; var stepAttrs = GetPluginStepAttributes(type); + // Skip Custom APIs — [CustomApi] takes precedence over any [PluginStep]. + if (HasCustomApiAttribute(type)) + { + if (verbose) + { + Out.Dim($" Skipping {type.FullName} — marked [CustomApi] (not an event plugin)"); + } + + if (stepAttrs.Count > 0) + { + Out.Warn( + $" {type.FullName} - has [CustomApi] AND [PluginStep] attributes. It should have only one or the other"); + } + + continue; + } + if (stepAttrs.Count == 0) { logger.LogWarning( @@ -148,6 +166,10 @@ private static List GetPluginStepAttributes(Type type) .Where(attr => attr.AttributeType.FullName == PluginStepAttrFullName) .ToList(); + private static bool HasCustomApiAttribute(Type type) + => type.GetCustomAttributesData() + .Any(attr => attr.AttributeType.FullName == CustomApiAttrFullName); + private static PluginStepDefinition BuildDefinition( string typeFullName, CustomAttributeData attr, bool verbose) { diff --git a/src/dvx/Services/SdkMetadata.cs b/src/dvx/Services/SdkMetadata.cs index a8e12d1..073bf76 100644 --- a/src/dvx/Services/SdkMetadata.cs +++ b/src/dvx/Services/SdkMetadata.cs @@ -12,6 +12,7 @@ internal class SdkMetadata(IOrganizationService svc) { private List? _messages; private List? _filters; + private List? _customApis; private Guid? _systemUserId; public Guid SystemUserId() @@ -49,6 +50,12 @@ public Guid SystemUserId() "primaryobjecttypecode", "iscustomprocessingstepallowed") }).Entities.ToList(); + private IReadOnlyList CustomApis => + _customApis ??= svc.RetrieveMultiple(new QueryExpression("customapi") + { + ColumnSet = new ColumnSet("customapiid", "uniquename", "plugintypeid", "sdkmessageid") + }).Entities.ToList(); + /// Message name → id (case-insensitive). public Dictionary MessageIdByName() { @@ -108,6 +115,30 @@ public Dictionary PluginTypeIdByName(Guid assemblyId) return map; } + /// plugintype ids referenced by a Custom API as its main-operation implementation. + public HashSet CustomApiPluginTypeIds() + { + var set = new HashSet(); + foreach (var e in CustomApis) + { + var typeRef = e.GetAttributeValue("plugintypeid"); + if (typeRef is not null) set.Add(typeRef.Id); + } + return set; + } + + /// sdkmessage ids of the messages created for Custom APIs. + public HashSet CustomApiMessageIds() + { + var set = new HashSet(); + foreach (var e in CustomApis) + { + var msgRef = e.GetAttributeValue("sdkmessageid"); + if (msgRef is not null) set.Add(msgRef.Id); + } + return set; + } + /// plugintype id → typename for the given assembly. public Dictionary PluginTypeNameById(Guid assemblyId) { diff --git a/src/dvx/Services/StepImporter.cs b/src/dvx/Services/StepImporter.cs index df9b3bd..bd7836c 100644 --- a/src/dvx/Services/StepImporter.cs +++ b/src/dvx/Services/StepImporter.cs @@ -29,6 +29,8 @@ public ImportResult Import(Guid assemblyId, bool verbose = false) var messageNameById = meta.MessageNameById(); var filterEntityById = meta.FilterEntityById(); + var customApiMessageIds = meta.CustomApiMessageIds(); + var customApiPluginTypeIds = meta.CustomApiPluginTypeIds(); foreach (var step in LoadSteps(typeNameById.Keys)) { @@ -54,12 +56,30 @@ public ImportResult Import(Guid assemblyId, bool verbose = false) ? e : string.Empty; + var stage = step.GetAttributeValue("stage")?.Value ?? 0; + + // Custom API plugins are not event plugins: their main operation runs at stage 30, + // their steps sit on the Custom API message, and their implementation type is + // referenced by customapi.plugintypeid. Skip them and record the class so adopt can + // mark it [CustomApi] instead of scaffolding [PluginStep]. + if (stage == 30 + || customApiMessageIds.Contains(msgRef.Id) + || customApiPluginTypeIds.Contains(typeRef.Id)) + { + RecordCustomApiType(result, typeName); + if (verbose) + logger.LogInformation( + "Skipping step {Name} on {Type} — Custom API (not an event plugin).", + stepName, typeName); + continue; + } + var def = new PluginStepDefinition { TypeFullName = typeName, Entity = entity, Message = message, - Stage = step.GetAttributeValue("stage")?.Value ?? 0, + Stage = stage, Mode = step.GetAttributeValue("mode")?.Value ?? 0, ExecutionOrder = step.GetAttributeValue("rank"), Description = NullIfEmpty(step.GetAttributeValue("description")), @@ -136,5 +156,11 @@ private static string[] SplitCsv(string? value) private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value; + + private static void RecordCustomApiType(ImportResult result, string typeName) + { + if (!result.CustomApiTypes.Contains(typeName)) + result.CustomApiTypes.Add(typeName); + } } } diff --git a/src/dvx/dvx.csproj b/src/dvx/dvx.csproj index fe5c5ed..cfc4165 100644 --- a/src/dvx/dvx.csproj +++ b/src/dvx/dvx.csproj @@ -6,8 +6,8 @@ enable true dvx - dvx - 1.6.0 + dvx.cli + 1.6.1 Byron Matus CLI for deploying code-first Dataverse / Power Platform artifacts — plugin assemblies and web resources. default