From 6790085589cc4c786890347bbd91a49527833bb6 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Tue, 30 Jun 2026 15:51:16 +0200 Subject: [PATCH 1/5] Add type-safe execute privilege helpers for Custom APIs Introduce a Privilege enum (Create/Read/Write/Delete/Append/AppendTo/ Assign/Share) in the Abstractions package and WithExecutePrivilege overloads on CustomApiConfigBuilder, resolving the platform privilege name from the entity logical name (e.g. prvReadaccount). The existing WithExecutePrivilegeName(string) remains for non-standard names. Co-Authored-By: Claude via Conducktor --- XrmPluginCore.Abstractions/CHANGELOG.md | 3 ++ XrmPluginCore.Abstractions/Enums/Privilege.cs | 52 +++++++++++++++++++ .../CustomApis/CustomApiConfigBuilderTests.cs | 47 +++++++++++++++++ XrmPluginCore/CHANGELOG.md | 1 + .../CustomApis/CustomApiConfigBuilder.cs | 25 +++++++++ .../Helpers/PrivilegeNameResolver.cs | 38 ++++++++++++++ 6 files changed, 166 insertions(+) create mode 100644 XrmPluginCore.Abstractions/Enums/Privilege.cs create mode 100644 XrmPluginCore/Helpers/PrivilegeNameResolver.cs diff --git a/XrmPluginCore.Abstractions/CHANGELOG.md b/XrmPluginCore.Abstractions/CHANGELOG.md index 7e374fb..04ef5f6 100644 --- a/XrmPluginCore.Abstractions/CHANGELOG.md +++ b/XrmPluginCore.Abstractions/CHANGELOG.md @@ -1,3 +1,6 @@ +### v1.2.0 - 30 June 2026 +* Add: `Privilege` enum of standard Dataverse table privileges (Create, Read, Write, Delete, Append, AppendTo, Assign, Share) for type-safe execute-privilege configuration on Custom APIs. + ### v1.1.0 - 08 October 2025 * Change Plugin Step Configs to not use the EventOperation enum but instead use a string to allow for custom messages. diff --git a/XrmPluginCore.Abstractions/Enums/Privilege.cs b/XrmPluginCore.Abstractions/Enums/Privilege.cs new file mode 100644 index 0000000..333652c --- /dev/null +++ b/XrmPluginCore.Abstractions/Enums/Privilege.cs @@ -0,0 +1,52 @@ +namespace XrmPluginCore.Enums +{ + /// + /// Standard Dataverse table (entity) privileges.
+ /// Each value maps to a privilege whose name follows the convention + /// prv{Privilege}{EntityLogicalName}, e.g. on the + /// account table resolves to prvReadaccount. + ///
+ public enum Privilege + { + /// + /// Permission to create a record (prvCreate...). + /// + Create = 0, + + /// + /// Permission to read a record (prvRead...). + /// + Read = 1, + + /// + /// Permission to update a record (prvWrite...). This is the "Update" of CRUD; + /// Dataverse names the update privilege "Write". + /// + Write = 2, + + /// + /// Permission to delete a record (prvDelete...). + /// + Delete = 3, + + /// + /// Permission to associate a record with this table from another record (prvAppend...). + /// + Append = 4, + + /// + /// Permission to associate another record with a record of this table (prvAppendTo...). + /// + AppendTo = 5, + + /// + /// Permission to assign a record to another user (prvAssign...). + /// + Assign = 6, + + /// + /// Permission to share a record with another user or team (prvShare...). + /// + Share = 7, + } +} diff --git a/XrmPluginCore.Tests/CustomApis/CustomApiConfigBuilderTests.cs b/XrmPluginCore.Tests/CustomApis/CustomApiConfigBuilderTests.cs index c479988..e017d8a 100644 --- a/XrmPluginCore.Tests/CustomApis/CustomApiConfigBuilderTests.cs +++ b/XrmPluginCore.Tests/CustomApis/CustomApiConfigBuilderTests.cs @@ -102,6 +102,53 @@ public void WithExecutePrivilegeName_CalledMultipleTimes_UsesLastValue() config.ExecutePrivilegeName.Should().Be("prvFinal"); } + [Theory] + [InlineData(Privilege.Create, "prvCreateaccount")] + [InlineData(Privilege.Read, "prvReadaccount")] + [InlineData(Privilege.Write, "prvWriteaccount")] + [InlineData(Privilege.Delete, "prvDeleteaccount")] + [InlineData(Privilege.Append, "prvAppendaccount")] + [InlineData(Privilege.AppendTo, "prvAppendToaccount")] + [InlineData(Privilege.Assign, "prvAssignaccount")] + [InlineData(Privilege.Share, "prvShareaccount")] + public void WithExecutePrivilege_Generic_ResolvesPrivilegeNameFromEntity(Privilege privilege, string expected) + { + // Arrange + var builder = new CustomApiConfigBuilder("test_api"); + + // Act + var config = builder.WithExecutePrivilege(privilege).Build(); + + // Assert + config.ExecutePrivilegeName.Should().Be(expected); + } + + [Fact] + public void WithExecutePrivilege_Generic_ReturnsBuilderForChaining() + { + // Arrange + var builder = new CustomApiConfigBuilder("test_api"); + + // Act + var result = builder.WithExecutePrivilege(Privilege.Read); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void WithExecutePrivilege_WithLogicalName_ResolvesPrivilegeName() + { + // Arrange + var builder = new CustomApiConfigBuilder("test_api"); + + // Act + var config = builder.WithExecutePrivilege("new_widget", Privilege.Write).Build(); + + // Assert + config.ExecutePrivilegeName.Should().Be("prvWritenew_widget"); + } + [Fact] public void Build_PreservesAllConfigurationIncludingExecutePrivilege() { diff --git a/XrmPluginCore/CHANGELOG.md b/XrmPluginCore/CHANGELOG.md index 9005c51..aba8655 100644 --- a/XrmPluginCore/CHANGELOG.md +++ b/XrmPluginCore/CHANGELOG.md @@ -1,4 +1,5 @@ ### v1.4.0 - 30 June 2026 +* Add: `WithExecutePrivilege(Privilege)` on the Custom API builder for type-safe execute-privilege configuration, e.g. `.WithExecutePrivilege(Privilege.Read)` resolves to `prvReadaccount`. A `WithExecutePrivilege(string entityLogicalName, Privilege)` overload is available for late-bound scenarios. The existing `WithExecutePrivilegeName(string)` remains for non-standard privilege names. Requires XrmPluginCore.Abstractions v1.2.0 for the new `Privilege` enum. * 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`. * Add: Error XPC4004: Custom API handler method not found (with code fix to create the method). * Add: Warning XPC4005 / Error XPC4006: Custom API handler signature does not match the declared request parameters and response properties (with code fix to correct the signature). diff --git a/XrmPluginCore/CustomApis/CustomApiConfigBuilder.cs b/XrmPluginCore/CustomApis/CustomApiConfigBuilder.cs index 27929d7..b2f54e4 100644 --- a/XrmPluginCore/CustomApis/CustomApiConfigBuilder.cs +++ b/XrmPluginCore/CustomApis/CustomApiConfigBuilder.cs @@ -119,6 +119,31 @@ public CustomApiConfigBuilder WithExecutePrivilegeName(string privilegeName) return this; } + /// + /// Sets the execute privilege to a standard table privilege of , + /// e.g. WithExecutePrivilege<Account>(Privilege.Read) resolves to prvReadaccount. + /// + /// The early-bound entity type the privilege applies to. + /// The CRUD privilege required to execute the custom API. + public CustomApiConfigBuilder WithExecutePrivilege(Privilege privilege) where T : Entity, new() + { + return WithExecutePrivilege(EntityLogicalNameCache.GetLogicalName(), privilege); + } + + /// + /// Sets the execute privilege to a standard table privilege of the entity identified by + /// , following the prv{Privilege}{EntityLogicalName} + /// convention. Use the generic overload when an + /// early-bound type is available. + /// + /// The logical name of the table the privilege applies to. + /// The CRUD privilege required to execute the custom API. + public CustomApiConfigBuilder WithExecutePrivilege(string entityLogicalName, Privilege privilege) + { + Config.ExecutePrivilegeName = PrivilegeNameResolver.GetExecutePrivilegeName(privilege, entityLogicalName); + return this; + } + public CustomApiConfigBuilder AddRequestParameter(string uniqueName, CustomApiParameterType type, string displayName = null, string description = null, diff --git a/XrmPluginCore/Helpers/PrivilegeNameResolver.cs b/XrmPluginCore/Helpers/PrivilegeNameResolver.cs new file mode 100644 index 0000000..bd3a8d7 --- /dev/null +++ b/XrmPluginCore/Helpers/PrivilegeNameResolver.cs @@ -0,0 +1,38 @@ +using System; +using XrmPluginCore.Enums; + +namespace XrmPluginCore.Helpers; + +internal static class PrivilegeNameResolver +{ + /// + /// Builds the Dataverse execute-privilege name for the given on the + /// supplied entity, following the prv{Privilege}{EntityLogicalName} convention. + /// For example, on account resolves to prvReadaccount. + /// + /// + /// Privilege names predate the schema name concept and are based on the entity logical name + /// (e.g. account), so the logical name is used verbatim. + /// + public static string GetExecutePrivilegeName(Privilege privilege, string entityLogicalName) + { + return $"prv{GetPrivilegeVerb(privilege)}{entityLogicalName}"; + } + + private static string GetPrivilegeVerb(Privilege privilege) + { + switch (privilege) + { + case Privilege.Create: return "Create"; + case Privilege.Read: return "Read"; + case Privilege.Write: return "Write"; + case Privilege.Delete: return "Delete"; + case Privilege.Append: return "Append"; + case Privilege.AppendTo: return "AppendTo"; + case Privilege.Assign: return "Assign"; + case Privilege.Share: return "Share"; + default: + throw new ArgumentOutOfRangeException(nameof(privilege), privilege, "Unknown privilege."); + } + } +} From b8663650081f197100d1a6113c8df90a3587f610 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Tue, 30 Jun 2026 15:51:16 +0200 Subject: [PATCH 2/5] Add type-safe execute privilege helpers for Custom APIs Introduce a Privilege enum (Create/Read/Write/Delete/Append/AppendTo/ Assign/Share) and WithExecutePrivilege overloads on CustomApiConfigBuilder, resolving the platform privilege name from the entity logical name (e.g. prvReadaccount). The existing WithExecutePrivilegeName(string) remains for non-standard names. The enum lives in the main package rather than Abstractions: it is input-side sugar consumed only at build time and resolved to the ExecutePrivilegeName string, so consumers like XrmSync/XrmMockup never need it. Co-Authored-By: Claude via Conducktor --- .../CustomApis/CustomApiConfigBuilderTests.cs | 47 +++++++++++++++++ XrmPluginCore/CHANGELOG.md | 1 + .../CustomApis/CustomApiConfigBuilder.cs | 25 +++++++++ XrmPluginCore/Enums/Privilege.cs | 52 +++++++++++++++++++ .../Helpers/PrivilegeNameResolver.cs | 38 ++++++++++++++ 5 files changed, 163 insertions(+) create mode 100644 XrmPluginCore/Enums/Privilege.cs create mode 100644 XrmPluginCore/Helpers/PrivilegeNameResolver.cs diff --git a/XrmPluginCore.Tests/CustomApis/CustomApiConfigBuilderTests.cs b/XrmPluginCore.Tests/CustomApis/CustomApiConfigBuilderTests.cs index c479988..e017d8a 100644 --- a/XrmPluginCore.Tests/CustomApis/CustomApiConfigBuilderTests.cs +++ b/XrmPluginCore.Tests/CustomApis/CustomApiConfigBuilderTests.cs @@ -102,6 +102,53 @@ public void WithExecutePrivilegeName_CalledMultipleTimes_UsesLastValue() config.ExecutePrivilegeName.Should().Be("prvFinal"); } + [Theory] + [InlineData(Privilege.Create, "prvCreateaccount")] + [InlineData(Privilege.Read, "prvReadaccount")] + [InlineData(Privilege.Write, "prvWriteaccount")] + [InlineData(Privilege.Delete, "prvDeleteaccount")] + [InlineData(Privilege.Append, "prvAppendaccount")] + [InlineData(Privilege.AppendTo, "prvAppendToaccount")] + [InlineData(Privilege.Assign, "prvAssignaccount")] + [InlineData(Privilege.Share, "prvShareaccount")] + public void WithExecutePrivilege_Generic_ResolvesPrivilegeNameFromEntity(Privilege privilege, string expected) + { + // Arrange + var builder = new CustomApiConfigBuilder("test_api"); + + // Act + var config = builder.WithExecutePrivilege(privilege).Build(); + + // Assert + config.ExecutePrivilegeName.Should().Be(expected); + } + + [Fact] + public void WithExecutePrivilege_Generic_ReturnsBuilderForChaining() + { + // Arrange + var builder = new CustomApiConfigBuilder("test_api"); + + // Act + var result = builder.WithExecutePrivilege(Privilege.Read); + + // Assert + result.Should().BeSameAs(builder); + } + + [Fact] + public void WithExecutePrivilege_WithLogicalName_ResolvesPrivilegeName() + { + // Arrange + var builder = new CustomApiConfigBuilder("test_api"); + + // Act + var config = builder.WithExecutePrivilege("new_widget", Privilege.Write).Build(); + + // Assert + config.ExecutePrivilegeName.Should().Be("prvWritenew_widget"); + } + [Fact] public void Build_PreservesAllConfigurationIncludingExecutePrivilege() { diff --git a/XrmPluginCore/CHANGELOG.md b/XrmPluginCore/CHANGELOG.md index 9005c51..d87ba3a 100644 --- a/XrmPluginCore/CHANGELOG.md +++ b/XrmPluginCore/CHANGELOG.md @@ -1,4 +1,5 @@ ### v1.4.0 - 30 June 2026 +* Add: `WithExecutePrivilege(Privilege)` on the Custom API builder for type-safe execute-privilege configuration, e.g. `.WithExecutePrivilege(Privilege.Read)` resolves to `prvReadaccount`. A `WithExecutePrivilege(string entityLogicalName, Privilege)` overload is available for late-bound scenarios. 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`. * Add: Error XPC4004: Custom API handler method not found (with code fix to create the method). * Add: Warning XPC4005 / Error XPC4006: Custom API handler signature does not match the declared request parameters and response properties (with code fix to correct the signature). diff --git a/XrmPluginCore/CustomApis/CustomApiConfigBuilder.cs b/XrmPluginCore/CustomApis/CustomApiConfigBuilder.cs index 27929d7..b2f54e4 100644 --- a/XrmPluginCore/CustomApis/CustomApiConfigBuilder.cs +++ b/XrmPluginCore/CustomApis/CustomApiConfigBuilder.cs @@ -119,6 +119,31 @@ public CustomApiConfigBuilder WithExecutePrivilegeName(string privilegeName) return this; } + /// + /// Sets the execute privilege to a standard table privilege of , + /// e.g. WithExecutePrivilege<Account>(Privilege.Read) resolves to prvReadaccount. + /// + /// The early-bound entity type the privilege applies to. + /// The CRUD privilege required to execute the custom API. + public CustomApiConfigBuilder WithExecutePrivilege(Privilege privilege) where T : Entity, new() + { + return WithExecutePrivilege(EntityLogicalNameCache.GetLogicalName(), privilege); + } + + /// + /// Sets the execute privilege to a standard table privilege of the entity identified by + /// , following the prv{Privilege}{EntityLogicalName} + /// convention. Use the generic overload when an + /// early-bound type is available. + /// + /// The logical name of the table the privilege applies to. + /// The CRUD privilege required to execute the custom API. + public CustomApiConfigBuilder WithExecutePrivilege(string entityLogicalName, Privilege privilege) + { + Config.ExecutePrivilegeName = PrivilegeNameResolver.GetExecutePrivilegeName(privilege, entityLogicalName); + return this; + } + public CustomApiConfigBuilder AddRequestParameter(string uniqueName, CustomApiParameterType type, string displayName = null, string description = null, diff --git a/XrmPluginCore/Enums/Privilege.cs b/XrmPluginCore/Enums/Privilege.cs new file mode 100644 index 0000000..333652c --- /dev/null +++ b/XrmPluginCore/Enums/Privilege.cs @@ -0,0 +1,52 @@ +namespace XrmPluginCore.Enums +{ + /// + /// Standard Dataverse table (entity) privileges.
+ /// Each value maps to a privilege whose name follows the convention + /// prv{Privilege}{EntityLogicalName}, e.g. on the + /// account table resolves to prvReadaccount. + ///
+ public enum Privilege + { + /// + /// Permission to create a record (prvCreate...). + /// + Create = 0, + + /// + /// Permission to read a record (prvRead...). + /// + Read = 1, + + /// + /// Permission to update a record (prvWrite...). This is the "Update" of CRUD; + /// Dataverse names the update privilege "Write". + /// + Write = 2, + + /// + /// Permission to delete a record (prvDelete...). + /// + Delete = 3, + + /// + /// Permission to associate a record with this table from another record (prvAppend...). + /// + Append = 4, + + /// + /// Permission to associate another record with a record of this table (prvAppendTo...). + /// + AppendTo = 5, + + /// + /// Permission to assign a record to another user (prvAssign...). + /// + Assign = 6, + + /// + /// Permission to share a record with another user or team (prvShare...). + /// + Share = 7, + } +} diff --git a/XrmPluginCore/Helpers/PrivilegeNameResolver.cs b/XrmPluginCore/Helpers/PrivilegeNameResolver.cs new file mode 100644 index 0000000..bd3a8d7 --- /dev/null +++ b/XrmPluginCore/Helpers/PrivilegeNameResolver.cs @@ -0,0 +1,38 @@ +using System; +using XrmPluginCore.Enums; + +namespace XrmPluginCore.Helpers; + +internal static class PrivilegeNameResolver +{ + /// + /// Builds the Dataverse execute-privilege name for the given on the + /// supplied entity, following the prv{Privilege}{EntityLogicalName} convention. + /// For example, on account resolves to prvReadaccount. + /// + /// + /// Privilege names predate the schema name concept and are based on the entity logical name + /// (e.g. account), so the logical name is used verbatim. + /// + public static string GetExecutePrivilegeName(Privilege privilege, string entityLogicalName) + { + return $"prv{GetPrivilegeVerb(privilege)}{entityLogicalName}"; + } + + private static string GetPrivilegeVerb(Privilege privilege) + { + switch (privilege) + { + case Privilege.Create: return "Create"; + case Privilege.Read: return "Read"; + case Privilege.Write: return "Write"; + case Privilege.Delete: return "Delete"; + case Privilege.Append: return "Append"; + case Privilege.AppendTo: return "AppendTo"; + case Privilege.Assign: return "Assign"; + case Privilege.Share: return "Share"; + default: + throw new ArgumentOutOfRangeException(nameof(privilege), privilege, "Unknown privilege."); + } + } +} From 7d624123ddcdd334022b1b375f548de3e874d660 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Wed, 1 Jul 2026 07:53:14 +0200 Subject: [PATCH 3/5] Clarify execute-privilege XML docs The Privilege enum covers more than CRUD (Append/AppendTo/Assign/Share), so describe the parameter as a "table privilege" rather than a "CRUD privilege" per review feedback. Co-Authored-By: Claude via Conducktor --- XrmPluginCore/CustomApis/CustomApiConfigBuilder.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/XrmPluginCore/CustomApis/CustomApiConfigBuilder.cs b/XrmPluginCore/CustomApis/CustomApiConfigBuilder.cs index b2f54e4..4bef6ec 100644 --- a/XrmPluginCore/CustomApis/CustomApiConfigBuilder.cs +++ b/XrmPluginCore/CustomApis/CustomApiConfigBuilder.cs @@ -124,7 +124,7 @@ public CustomApiConfigBuilder WithExecutePrivilegeName(string privilegeName) /// e.g. WithExecutePrivilege<Account>(Privilege.Read) resolves to prvReadaccount. /// /// The early-bound entity type the privilege applies to. - /// The CRUD privilege required to execute the custom API. + /// The table privilege required to execute the custom API. public CustomApiConfigBuilder WithExecutePrivilege(Privilege privilege) where T : Entity, new() { return WithExecutePrivilege(EntityLogicalNameCache.GetLogicalName(), privilege); @@ -137,7 +137,7 @@ public CustomApiConfigBuilder WithExecutePrivilegeName(string privilegeName) /// early-bound type is available. /// /// The logical name of the table the privilege applies to. - /// The CRUD privilege required to execute the custom API. + /// The table privilege required to execute the custom API. public CustomApiConfigBuilder WithExecutePrivilege(string entityLogicalName, Privilege privilege) { Config.ExecutePrivilegeName = PrivilegeNameResolver.GetExecutePrivilegeName(privilege, entityLogicalName); From 762400603f4398126319362abc09478260f549e7 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Wed, 1 Jul 2026 08:28:51 +0200 Subject: [PATCH 4/5] Resolve execute privilege names from the entity schema name Dataverse table privilege names use the entity schema name (e.g. prvReadAccount), not the logical name (account) which is the lowercased schema name and thus loses the original casing. Take the schema name from the early-bound type name (typeof(T).Name), which generators produce from the schema name, so WithExecutePrivilege(Read) now resolves to prvReadAccount. The string overload takes the schema name directly. Co-Authored-By: Claude via Conducktor --- .../CustomApis/CustomApiConfigBuilderTests.cs | 25 ++++++++++--------- XrmPluginCore/CHANGELOG.md | 2 +- .../CustomApis/CustomApiConfigBuilder.cs | 21 +++++++++------- XrmPluginCore/Enums/Privilege.cs | 4 +-- .../Helpers/PrivilegeNameResolver.cs | 13 +++++----- 5 files changed, 35 insertions(+), 30 deletions(-) diff --git a/XrmPluginCore.Tests/CustomApis/CustomApiConfigBuilderTests.cs b/XrmPluginCore.Tests/CustomApis/CustomApiConfigBuilderTests.cs index e017d8a..45d7b4e 100644 --- a/XrmPluginCore.Tests/CustomApis/CustomApiConfigBuilderTests.cs +++ b/XrmPluginCore.Tests/CustomApis/CustomApiConfigBuilderTests.cs @@ -103,20 +103,21 @@ public void WithExecutePrivilegeName_CalledMultipleTimes_UsesLastValue() } [Theory] - [InlineData(Privilege.Create, "prvCreateaccount")] - [InlineData(Privilege.Read, "prvReadaccount")] - [InlineData(Privilege.Write, "prvWriteaccount")] - [InlineData(Privilege.Delete, "prvDeleteaccount")] - [InlineData(Privilege.Append, "prvAppendaccount")] - [InlineData(Privilege.AppendTo, "prvAppendToaccount")] - [InlineData(Privilege.Assign, "prvAssignaccount")] - [InlineData(Privilege.Share, "prvShareaccount")] - public void WithExecutePrivilege_Generic_ResolvesPrivilegeNameFromEntity(Privilege privilege, string expected) + [InlineData(Privilege.Create, "prvCreateAccount")] + [InlineData(Privilege.Read, "prvReadAccount")] + [InlineData(Privilege.Write, "prvWriteAccount")] + [InlineData(Privilege.Delete, "prvDeleteAccount")] + [InlineData(Privilege.Append, "prvAppendAccount")] + [InlineData(Privilege.AppendTo, "prvAppendToAccount")] + [InlineData(Privilege.Assign, "prvAssignAccount")] + [InlineData(Privilege.Share, "prvShareAccount")] + public void WithExecutePrivilege_Generic_ResolvesPrivilegeNameFromSchemaName(Privilege privilege, string expected) { // Arrange var builder = new CustomApiConfigBuilder("test_api"); // Act + // Privilege names use the schema name, which the early-bound type name (typeof(Account).Name = "Account") provides. var config = builder.WithExecutePrivilege(privilege).Build(); // Assert @@ -137,16 +138,16 @@ public void WithExecutePrivilege_Generic_ReturnsBuilderForChaining() } [Fact] - public void WithExecutePrivilege_WithLogicalName_ResolvesPrivilegeName() + public void WithExecutePrivilege_WithSchemaName_ResolvesPrivilegeName() { // Arrange var builder = new CustomApiConfigBuilder("test_api"); // Act - var config = builder.WithExecutePrivilege("new_widget", Privilege.Write).Build(); + var config = builder.WithExecutePrivilege("new_Widget", Privilege.Write).Build(); // Assert - config.ExecutePrivilegeName.Should().Be("prvWritenew_widget"); + config.ExecutePrivilegeName.Should().Be("prvWritenew_Widget"); } [Fact] diff --git a/XrmPluginCore/CHANGELOG.md b/XrmPluginCore/CHANGELOG.md index d87ba3a..40b6b02 100644 --- a/XrmPluginCore/CHANGELOG.md +++ b/XrmPluginCore/CHANGELOG.md @@ -1,5 +1,5 @@ ### v1.4.0 - 30 June 2026 -* Add: `WithExecutePrivilege(Privilege)` on the Custom API builder for type-safe execute-privilege configuration, e.g. `.WithExecutePrivilege(Privilege.Read)` resolves to `prvReadaccount`. A `WithExecutePrivilege(string entityLogicalName, Privilege)` overload is available for late-bound scenarios. The existing `WithExecutePrivilegeName(string)` remains for non-standard privilege names. +* 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`. * Add: Error XPC4004: Custom API handler method not found (with code fix to create the method). * Add: Warning XPC4005 / Error XPC4006: Custom API handler signature does not match the declared request parameters and response properties (with code fix to correct the signature). diff --git a/XrmPluginCore/CustomApis/CustomApiConfigBuilder.cs b/XrmPluginCore/CustomApis/CustomApiConfigBuilder.cs index 4bef6ec..3a558c7 100644 --- a/XrmPluginCore/CustomApis/CustomApiConfigBuilder.cs +++ b/XrmPluginCore/CustomApis/CustomApiConfigBuilder.cs @@ -121,26 +121,29 @@ public CustomApiConfigBuilder WithExecutePrivilegeName(string privilegeName) /// /// Sets the execute privilege to a standard table privilege of , - /// e.g. WithExecutePrivilege<Account>(Privilege.Read) resolves to prvReadaccount. + /// e.g. WithExecutePrivilege<Account>(Privilege.Read) resolves to prvReadAccount. + /// The entity schema name is taken from the early-bound type name (typeof(T).Name), which + /// early-bound generators produce from the entity schema name. /// /// The early-bound entity type the privilege applies to. /// The table privilege required to execute the custom API. - public CustomApiConfigBuilder WithExecutePrivilege(Privilege privilege) where T : Entity, new() + public CustomApiConfigBuilder WithExecutePrivilege(Privilege privilege) where T : Entity { - return WithExecutePrivilege(EntityLogicalNameCache.GetLogicalName(), privilege); + return WithExecutePrivilege(typeof(T).Name, privilege); } /// /// Sets the execute privilege to a standard table privilege of the entity identified by - /// , following the prv{Privilege}{EntityLogicalName} - /// convention. Use the generic overload when an - /// early-bound type is available. + /// , following the prv{Privilege}{EntitySchemaName} + /// convention. Privilege names use the schema name (e.g. Account), not the logical name + /// (account). Prefer the generic overload + /// when an early-bound type is available. /// - /// The logical name of the table the privilege applies to. + /// The schema name of the table the privilege applies to. /// The table privilege required to execute the custom API. - public CustomApiConfigBuilder WithExecutePrivilege(string entityLogicalName, Privilege privilege) + public CustomApiConfigBuilder WithExecutePrivilege(string entitySchemaName, Privilege privilege) { - Config.ExecutePrivilegeName = PrivilegeNameResolver.GetExecutePrivilegeName(privilege, entityLogicalName); + Config.ExecutePrivilegeName = PrivilegeNameResolver.GetExecutePrivilegeName(privilege, entitySchemaName); return this; } diff --git a/XrmPluginCore/Enums/Privilege.cs b/XrmPluginCore/Enums/Privilege.cs index 333652c..fbf488f 100644 --- a/XrmPluginCore/Enums/Privilege.cs +++ b/XrmPluginCore/Enums/Privilege.cs @@ -3,8 +3,8 @@ namespace XrmPluginCore.Enums /// /// Standard Dataverse table (entity) privileges.
/// Each value maps to a privilege whose name follows the convention - /// prv{Privilege}{EntityLogicalName}, e.g. on the - /// account table resolves to prvReadaccount. + /// prv{Privilege}{EntitySchemaName}, e.g. on the + /// Account table resolves to prvReadAccount. ///
public enum Privilege { diff --git a/XrmPluginCore/Helpers/PrivilegeNameResolver.cs b/XrmPluginCore/Helpers/PrivilegeNameResolver.cs index bd3a8d7..3e30baa 100644 --- a/XrmPluginCore/Helpers/PrivilegeNameResolver.cs +++ b/XrmPluginCore/Helpers/PrivilegeNameResolver.cs @@ -7,16 +7,17 @@ internal static class PrivilegeNameResolver { /// /// Builds the Dataverse execute-privilege name for the given on the - /// supplied entity, following the prv{Privilege}{EntityLogicalName} convention. - /// For example, on account resolves to prvReadaccount. + /// supplied entity, following the prv{Privilege}{EntitySchemaName} convention. + /// For example, on Account resolves to prvReadAccount. /// /// - /// Privilege names predate the schema name concept and are based on the entity logical name - /// (e.g. account), so the logical name is used verbatim. + /// Privilege names use the entity schema name (e.g. Account), not the logical name + /// (account): the platform stores the logical name as the lowercased schema name, so the + /// original casing must come from the schema name. Callers pass it via the early-bound type name. /// - public static string GetExecutePrivilegeName(Privilege privilege, string entityLogicalName) + public static string GetExecutePrivilegeName(Privilege privilege, string entitySchemaName) { - return $"prv{GetPrivilegeVerb(privilege)}{entityLogicalName}"; + return $"prv{GetPrivilegeVerb(privilege)}{entitySchemaName}"; } private static string GetPrivilegeVerb(Privilege privilege) From 70182cfaef0a6176ee830f656fe6350d486d0a88 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Wed, 1 Jul 2026 08:50:13 +0200 Subject: [PATCH 5/5] Update CHANGELOG.md --- XrmPluginCore/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/XrmPluginCore/CHANGELOG.md b/XrmPluginCore/CHANGELOG.md index ea4039c..ab27c9a 100644 --- a/XrmPluginCore/CHANGELOG.md +++ b/XrmPluginCore/CHANGELOG.md @@ -1,4 +1,4 @@ -### v1.4.0 - 01 July 2026 +### 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`. * Add: Error XPC4004: Custom API handler method not found (with code fix to create the method).