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: 16 additions & 6 deletions .github/workflows/pr-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,26 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4

- name: Ensure local-nuget directory exists
run: mkdir -p local-nuget

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'

- name: Restore dependencies
run: dotnet restore

- name: Build all projects
run: dotnet build --configuration Release --no-restore
- name: Build DataverseConnection
run: dotnet build DataverseConnection/DataverseConnection.csproj --configuration Release

- name: Pack DataverseConnection
run: dotnet pack DataverseConnection/DataverseConnection.csproj --configuration Release --no-build -o out
run: dotnet pack DataverseConnection/DataverseConnection.csproj --configuration Release --no-build -o local-nuget /p:Version=1.0.0-localpr

- name: Update WhoAmI PackageReference to localpr version
run: |
sed -i 's|<PackageReference Include="DataverseConnection" Version=".*"|<PackageReference Include="DataverseConnection" Version="1.0.0-localpr"|' DataverseWhoAmI/DataverseWhoAmI.csproj

- name: Restore WhoAmI with local NuGet
run: dotnet restore DataverseWhoAmI/DataverseWhoAmI.csproj --no-cache --force-evaluate

- name: Build WhoAmI
run: dotnet build DataverseWhoAmI/DataverseWhoAmI.csproj --configuration Release --no-restore
2 changes: 1 addition & 1 deletion DataverseConnection/DataverseConnection.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<ItemGroup>
<PackageReference Include="Azure.Identity" Version="1.13.2" />
<PackageReference Include="Microsoft.PowerPlatform.Dataverse.Client" Version="1.2.7" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
</ItemGroup>

Expand Down
20 changes: 20 additions & 0 deletions DataverseConnection/IServiceClientFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Microsoft.PowerPlatform.Dataverse.Client;

namespace DataverseConnection
{
/// <summary>
/// Factory interface for creating new instances of <see cref="ServiceClient"/>.
/// Use for advanced scenarios such as long-running jobs or when multiple independent clients are required.
/// </summary>
public interface IServiceClientFactory
{
/// <summary>
/// Creates a new <see cref="ServiceClient"/> instance.
/// </summary>
/// <param name="options">
/// Optional Dataverse connection options. If null, uses default configuration.
/// </param>
/// <returns>A new <see cref="ServiceClient"/> instance.</returns>
ServiceClient CreateClient(DataverseOptions? options = null);
}
}
73 changes: 73 additions & 0 deletions DataverseConnection/Internal/ServiceClientBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System;
using System.Threading.Tasks;
using Azure.Core;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.PowerPlatform.Dataverse.Client;

namespace DataverseConnection.Internal
{
/// <summary>
/// Internal helper for constructing ServiceClient instances with consistent logic.
/// </summary>
internal static class ServiceClientBuilder
{
public static ServiceClient Build(
DataverseOptions options,
IMemoryCache memoryCache,
IConfiguration? configuration,
TokenCredential defaultCredential)
{
// Determine DataverseUrl: options first, then configuration
string? dataverseUrl = options.DataverseUrl;
if (string.IsNullOrWhiteSpace(dataverseUrl))
{
dataverseUrl = configuration?["DATAVERSE_URL"];
}

if (string.IsNullOrWhiteSpace(dataverseUrl))
throw new InvalidOperationException("DataverseUrl must be provided via options or configuration (DATAVERSE_URL).");

var credential = options.TokenCredential ?? defaultCredential;
var resource = $"{new Uri(dataverseUrl).GetLeftPart(UriPartial.Authority)}/.default";

// Token provider function for ServiceClient
async Task<string> TokenProvider(string url)
{
var cacheKey = $"dataverse_token_{resource}";
if (memoryCache.TryGetValue<string>(cacheKey, out var cachedToken) && cachedToken != null)
return cachedToken;

var tokenRequestContext = new TokenRequestContext([resource]);
var token = await credential.GetTokenAsync(tokenRequestContext, default);

// Set expiration 5 minutes before actual expiry, but never in the past
var expiresOn = token.ExpiresOn.UtcDateTime;
var now = DateTime.UtcNow;
var expiration = expiresOn - TimeSpan.FromMinutes(5);
if (expiration <= now)
{
// If token lifetime is less than 5 minutes, expire 1 minute before, or immediately if needed
expiration = expiresOn > now.AddMinutes(1) ? expiresOn - TimeSpan.FromMinutes(1) : now.AddSeconds(10);
}

var cacheEntryOptions = new MemoryCacheEntryOptions
{
AbsoluteExpiration = expiration
};
memoryCache.Set(cacheKey, token.Token, cacheEntryOptions);

return token.Token;
}

var serviceClient = new ServiceClient(
new Uri(dataverseUrl),
tokenProviderFunction: TokenProvider);

if (!serviceClient.IsReady)
throw new InvalidOperationException("ServiceClient is not ready. Check your credentials and Dataverse URL.");

return serviceClient;
}
}
}
46 changes: 46 additions & 0 deletions DataverseConnection/ServiceClientFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using System;
using System.Threading.Tasks;
using Azure.Core;
using Azure.Identity;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.PowerPlatform.Dataverse.Client;

namespace DataverseConnection
{
/// <summary>
/// Factory for creating new instances of <see cref="ServiceClient"/>.
/// Use for advanced scenarios such as long-running jobs or when multiple independent clients are required.
/// </summary>
public class ServiceClientFactory : IServiceClientFactory
{
private readonly IMemoryCache _memoryCache;
private readonly IConfiguration _configuration;
private readonly TokenCredential _defaultCredential;
private readonly DataverseOptions _defaultOptions;

public ServiceClientFactory(
IMemoryCache memoryCache,
IConfiguration configuration,
TokenCredential? defaultCredential = null,
DataverseOptions? defaultOptions = null)
{
_memoryCache = memoryCache;
_configuration = configuration;
_defaultCredential = defaultCredential ?? new DefaultAzureCredential();
_defaultOptions = defaultOptions ?? new DataverseOptions();
}

public ServiceClient CreateClient(DataverseOptions? options = null)
{
var effectiveOptions = options ?? _defaultOptions;

return Internal.ServiceClientBuilder.Build(
effectiveOptions,
_memoryCache,
_configuration,
_defaultCredential
);
}
}
}
101 changes: 53 additions & 48 deletions DataverseConnection/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Microsoft.PowerPlatform.Dataverse.Client;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Xrm.Sdk;

namespace DataverseConnection
{
Expand Down Expand Up @@ -46,62 +47,66 @@ public static IServiceCollection AddDataverse(this IServiceCollection services,

services.AddSingleton(sp =>
{
// Determine DataverseUrl: options first, then configuration
string? dataverseUrl = options.DataverseUrl;
if (string.IsNullOrWhiteSpace(dataverseUrl))
{
var config = sp.GetService<IConfiguration>();
dataverseUrl = config?["DATAVERSE_URL"];
}

if (string.IsNullOrWhiteSpace(dataverseUrl))
throw new InvalidOperationException("DataverseUrl must be provided via options or configuration (DATAVERSE_URL).");

var credential = options.TokenCredential ?? new DefaultAzureCredential();
var resource = $"{new Uri(dataverseUrl).GetLeftPart(UriPartial.Authority)}/.default";

var memoryCache = sp.GetRequiredService<IMemoryCache>();
var configuration = sp.GetService<IConfiguration>();
var defaultCredential = new DefaultAzureCredential();
return Internal.ServiceClientBuilder.Build(
options,
memoryCache,
configuration,
defaultCredential
);
});

// Token provider function for ServiceClient
async Task<string> TokenProvider(string url)
{
var cacheKey = $"dataverse_token_{resource}";
if (memoryCache.TryGetValue<string>(cacheKey, out var cachedToken) && cachedToken != null)
return cachedToken;

var tokenRequestContext = new TokenRequestContext([resource]);
var token = await credential.GetTokenAsync(tokenRequestContext, default);

// Set expiration 5 minutes before actual expiry, but never in the past
var expiresOn = token.ExpiresOn.UtcDateTime;
var now = DateTime.UtcNow;
var expiration = expiresOn - TimeSpan.FromMinutes(5);
if (expiration <= now)
{
// If token lifetime is less than 5 minutes, expire 1 minute before, or immediately if needed
expiration = expiresOn > now.AddMinutes(1) ? expiresOn - TimeSpan.FromMinutes(1) : now.AddSeconds(10);
}

var cacheEntryOptions = new MemoryCacheEntryOptions
{
AbsoluteExpiration = expiration
};
memoryCache.Set(cacheKey, token.Token, cacheEntryOptions);

return token.Token;
}
return services;
}

var serviceClient = new ServiceClient(
new Uri(dataverseUrl),
tokenProviderFunction: TokenProvider);
/// <summary>
/// Registers a ServiceClientFactory for advanced scenarios where new ServiceClient instances are required.
/// Existing ServiceClient and interface registrations remain unchanged.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configureOptions">Optional action to configure default DataverseOptions for the factory.</param>
/// <param name="defaultCredential">Optional default TokenCredential for the factory.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddDataverseFactory(
this IServiceCollection services,
Action<DataverseOptions>? configureOptions = null,
TokenCredential? defaultCredential = null)
{
var options = new DataverseOptions();
configureOptions?.Invoke(options);

if (!serviceClient.IsReady)
throw new InvalidOperationException("ServiceClient is not ready. Check your credentials and Dataverse URL.");
services.AddMemoryCache();

return serviceClient;
services.AddSingleton<IServiceClientFactory>(sp =>
{
var memoryCache = sp.GetRequiredService<IMemoryCache>();
var configuration = sp.GetRequiredService<IConfiguration>();
return new ServiceClientFactory(
memoryCache,
configuration,
defaultCredential,
options
);
});

return services;
}

/// <summary>
/// Registers a Dataverse ServiceClient and all related organization service interfaces for dependency injection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configureOptions">Action to configure DataverseOptions.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddDataverseWithOrganizationServices(this IServiceCollection services, Action<DataverseOptions>? configureOptions = null)
{
services.AddDataverse(configureOptions);
services.AddSingleton<IOrganizationServiceAsync2>(sp => sp.GetRequiredService<ServiceClient>());
services.AddSingleton<IOrganizationServiceAsync>(sp => sp.GetRequiredService<ServiceClient>());
services.AddSingleton<IOrganizationService>(sp => sp.GetRequiredService<ServiceClient>());
return services;
}
}
}
51 changes: 43 additions & 8 deletions DataverseWhoAmI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.Crm.Sdk.Messages;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Xrm.Sdk;
using DataverseConnection;

class Program
Expand All @@ -18,21 +19,47 @@ static async Task<int> Main(string[] args)
.AddEnvironmentVariables()
.Build();

// Setup DI and register ServiceClient using AddDataverse
// Setup DI and register ServiceClient and interfaces
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(configuration);
services.AddDataverse();
services.AddDataverseWithOrganizationServices();
services.AddDataverseFactory();

using var serviceProvider = services.BuildServiceProvider();
var serviceClient = serviceProvider.GetRequiredService<ServiceClient>();

var whoAmIRequest = new WhoAmIRequest();
var response = (WhoAmIResponse) await serviceClient.ExecuteAsync(whoAmIRequest);

Console.WriteLine("WhoAmIRequest Result:");
Console.WriteLine($" UserId: {response.UserId}");
Console.WriteLine($" BusinessUnitId: {response.BusinessUnitId}");
Console.WriteLine($" OrganizationId: {response.OrganizationId}");
// 1. ServiceClient
var serviceClient = serviceProvider.GetRequiredService<ServiceClient>();
var response1 = (WhoAmIResponse)await serviceClient.ExecuteAsync(whoAmIRequest);
Console.WriteLine("WhoAmIRequest via ServiceClient:");
PrintResponse(response1);

// 2. IOrganizationServiceAsync2
var orgServiceAsync2 = serviceProvider.GetRequiredService<IOrganizationServiceAsync2>();
var response2 = (WhoAmIResponse)await orgServiceAsync2.ExecuteAsync(whoAmIRequest);
Console.WriteLine("WhoAmIRequest via IOrganizationServiceAsync2:");
PrintResponse(response2);

// 3. IOrganizationServiceAsync
var orgServiceAsync = serviceProvider.GetRequiredService<IOrganizationServiceAsync>();
var response3 = (WhoAmIResponse)await orgServiceAsync.ExecuteAsync(whoAmIRequest);
Console.WriteLine("WhoAmIRequest via IOrganizationServiceAsync:");
PrintResponse(response3);

// 4. IOrganizationService (sync)
var orgService = serviceProvider.GetRequiredService<IOrganizationService>();
var response4 = (WhoAmIResponse)orgService.Execute(whoAmIRequest);
Console.WriteLine("WhoAmIRequest via IOrganizationService (sync):");
PrintResponse(response4);

// 5. Client from factory
var factory = serviceProvider.GetRequiredService<IServiceClientFactory>();
var factoryClient = factory.CreateClient();
var response5 = (WhoAmIResponse)await factoryClient.ExecuteAsync(whoAmIRequest);
Console.WriteLine("WhoAmIRequest via IServiceClientFactory:");
PrintResponse(response5);

return 0;
}
catch (Exception ex)
Expand All @@ -42,4 +69,12 @@ static async Task<int> Main(string[] args)
return 99;
}
}

static void PrintResponse(WhoAmIResponse response)
{
Console.WriteLine($" UserId: {response.UserId}");
Console.WriteLine($" BusinessUnitId: {response.BusinessUnitId}");
Console.WriteLine($" OrganizationId: {response.OrganizationId}");
Console.WriteLine();
}
}
1 change: 1 addition & 0 deletions NuGet.config
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="local" value="local-nuget" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>
Loading