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
47 changes: 47 additions & 0 deletions XrmPluginCore.Tests/CustomApis/CustomApiConfigBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Account>(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<Account>(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()
{
Expand Down
1 change: 1 addition & 0 deletions XrmPluginCore/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
### v1.4.0 - 30 June 2026
* Add: `WithExecutePrivilege<T>(Privilege)` on the Custom API builder for type-safe execute-privilege configuration, e.g. `.WithExecutePrivilege<Account>(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<TService>(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).
Expand Down
25 changes: 25 additions & 0 deletions XrmPluginCore/CustomApis/CustomApiConfigBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,31 @@ public CustomApiConfigBuilder WithExecutePrivilegeName(string privilegeName)
return this;
}

/// <summary>
/// Sets the execute privilege to a standard table privilege of <typeparamref name="T"/>,
/// e.g. <c>WithExecutePrivilege&lt;Account&gt;(Privilege.Read)</c> resolves to <c>prvReadaccount</c>.
/// </summary>
/// <typeparam name="T">The early-bound entity type the privilege applies to.</typeparam>
/// <param name="privilege">The table privilege required to execute the custom API.</param>
public CustomApiConfigBuilder WithExecutePrivilege<T>(Privilege privilege) where T : Entity, new()
Comment thread
Copilot marked this conversation as resolved.
{
return WithExecutePrivilege(EntityLogicalNameCache.GetLogicalName<T>(), privilege);
}

/// <summary>
/// Sets the execute privilege to a standard table privilege of the entity identified by
/// <paramref name="entityLogicalName"/>, following the <c>prv{Privilege}{EntityLogicalName}</c>
/// convention. Use the generic <see cref="WithExecutePrivilege{T}(Privilege)"/> overload when an
/// early-bound type is available.
/// </summary>
/// <param name="entityLogicalName">The logical name of the table the privilege applies to.</param>
/// <param name="privilege">The table privilege required to execute the custom API.</param>
public CustomApiConfigBuilder WithExecutePrivilege(string entityLogicalName, Privilege privilege)
Comment thread
Copilot marked this conversation as resolved.
{
Config.ExecutePrivilegeName = PrivilegeNameResolver.GetExecutePrivilegeName(privilege, entityLogicalName);
return this;
}

public CustomApiConfigBuilder AddRequestParameter(string uniqueName, CustomApiParameterType type,
string displayName = null,
string description = null,
Expand Down
52 changes: 52 additions & 0 deletions XrmPluginCore/Enums/Privilege.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
namespace XrmPluginCore.Enums
{
/// <summary>
/// Standard Dataverse table (entity) privileges.<br/>
Comment thread
mkholt marked this conversation as resolved.
/// Each value maps to a privilege whose name follows the convention
Comment thread
mkholt marked this conversation as resolved.
/// <c>prv{Privilege}{EntityLogicalName}</c>, e.g. <see cref="Read"/> on the
/// <c>account</c> table resolves to <c>prvReadaccount</c>.
/// </summary>
public enum Privilege
{
/// <summary>
/// Permission to create a record (<c>prvCreate...</c>).
/// </summary>
Create = 0,

/// <summary>
/// Permission to read a record (<c>prvRead...</c>).
/// </summary>
Read = 1,

/// <summary>
/// Permission to update a record (<c>prvWrite...</c>). This is the "Update" of CRUD;
/// Dataverse names the update privilege "Write".
/// </summary>
Write = 2,

/// <summary>
/// Permission to delete a record (<c>prvDelete...</c>).
/// </summary>
Delete = 3,

/// <summary>
/// Permission to associate a record with this table from another record (<c>prvAppend...</c>).
/// </summary>
Append = 4,

/// <summary>
/// Permission to associate another record with a record of this table (<c>prvAppendTo...</c>).
/// </summary>
AppendTo = 5,

/// <summary>
/// Permission to assign a record to another user (<c>prvAssign...</c>).
/// </summary>
Assign = 6,

/// <summary>
/// Permission to share a record with another user or team (<c>prvShare...</c>).
/// </summary>
Share = 7,
}
}
38 changes: 38 additions & 0 deletions XrmPluginCore/Helpers/PrivilegeNameResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System;
using XrmPluginCore.Enums;

namespace XrmPluginCore.Helpers;

internal static class PrivilegeNameResolver
{
/// <summary>
/// Builds the Dataverse execute-privilege name for the given <paramref name="privilege"/> on the
/// supplied entity, following the <c>prv{Privilege}{EntityLogicalName}</c> convention.
/// For example, <see cref="Privilege.Read"/> on <c>account</c> resolves to <c>prvReadaccount</c>.
/// </summary>
/// <remarks>
/// Privilege names predate the schema name concept and are based on the entity logical name
/// (e.g. <c>account</c>), so the logical name is used verbatim.
/// </remarks>
public static string GetExecutePrivilegeName(Privilege privilege, string entityLogicalName)
{
return $"prv{GetPrivilegeVerb(privilege)}{entityLogicalName}";
}
Comment thread
mkholt marked this conversation as resolved.

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.");
}
}
}
Loading