The CoreEx.OData
namespace provides extended OData v4 support leveraging the Simple.OData.Client
open-source capabilities.
The motivation is to simplify and unify the approach to OData access. The Simple.OData.Client
provides, as the name implies, a simple and easy to use OData client.
The ODataClient
class is a wrapper around the Simple.OData.Client.ODataClient
. Yes, they have the same name; sadly, naming stuff is hard.
The ODataClient
is the base (common) implementation for the IOData
interface that provides the standardized access to the underlying endpoint. For typed access an IMapper that contains the mapping logic to map to and from the entity and underlying model ia required.
The requirements for usage are as follows.
- An entity (DTO) that represents the data that must as a minimum implement
IEntityKey
; generally via either the implementation ofIIdentifier
orIPrimaryKey
. - A model being the underlying configured JSON-serializable representation of the data source model.
- An
IMapper
that contains the mapping logic to map to and from the entity and model.
The entity and model are different types to encourage separation between the externalized entity representation and the underlying model; which may be shaped differently, and have different property to column naming conventions, etc.
Additionally, untyped model access is also supported via an ODataItem
(dictionary-based representation) and the ODataItemCollection
, a CRUD-enabler for untyped.
The IOData
and corresponding ODataClient
provide the base CRUD capabilities as follows:
A query is actioned using the ODataQuery
which is ostensibly a lighweight wrapper over an IBoundClient<TModel>
(https://github.com/simple-odata-client/Simple.OData.Client/blob/master/src/Simple.OData.Client.Core/Fluent/IBoundClient.cs) that automatically maps from the model to the entity.
The following methods provide additional capabilities:
Method | Description |
---|---|
WithPaging |
Adds Skip and Take paging to the query. |
SelectSingleAsync , SelectSingleWithResult |
Selects a single item. |
SelectSingleOrDefaultAsync , SelectSingleOrDefaultWithResultAsync |
Selects a single item or default. |
SelectFirstAsync , SelectFirstWithResultAsync |
Selects first item. |
SelectFirstOrDefaultAsync , SelectFirstOrDefaultWithResultAsync |
Selects first item or default. |
SelectQueryAsync , SelectQueryWithResultAsync |
Select items into or creating a resultant collection. |
SelectResultAsync , SelectResultWithResultAsync |
Select items creating a ICollectionResult which also contains corresponding PagingResult . |
Gets (GetAsync
or GetWithResultAsync
) the entity for the specified key mapping from the model. Uses Simple.OData.Client
internally to get the model using the specified key.
Creates (CreateAsync
or CreateWithResultAsync
) the entity by firstly mapping to the model. Uses Simple.OData.Client
to insert.
Where the entity implements IChangeLogAuditLog
generally via ChangeLog
or ChangeLogEx
, then the CreatedBy
and CreatedDate
properties will be automatically set from the ExecutionContext
.
Where the entity and/or model implements ITenantId
then the TenantId
property will be automatically set from the ExecutionContext
.
Updates (UpdateAsync
or UpdateWithResultAsync
) the entity by firstly mapping to the model. Uses Simple.OData.Client
to update.
Where the entity implements IChangeLogAuditLog
generally via ChangeLog
or ChangeLogEx
, then the UpdatedBy
and UpdatedDate
properties will be automatically set from the ExecutionContext
.
Where the entity and/or model implements ITenantId
then the TenantId
property will be automatically set from the ExecutionContext
.
Deletes (DeleteAsync
or DeleteWithResultAsync
) the entity. Uses Simple.OData.Client
to delete.
Untyped refers to the support of a model that is not defined as a type at compile time; is from a Simple.OData.Client.ODataClient
perspective a IDictionary<string, object>
. The ODataItem
encapsulates the dictionary-based representation with the corresponding ODataItemCollection
enabling CRUD operations.
The ODataMapper
provides the mapping logic to map to and from the entity and untyped model dictionary. The following demonstrates:
public class CustomerToDataverseAccountMapper : ODataMapper<Customer>
{
public CustomerToDataverseAccountMapper()
{
Property(c => c.AccountId, "accountid", OperationTypes.AnyExceptCreate).SetPrimaryKey();
Property(x => x.FirstName, "firstname");
Property(x => x.LastName, "lastname");
}
}
To use the Simple.OData.Client.ODataClient
must first be instantiated, then passed to the ODataClient
constructor including a reference to the IMapper
.
The following will demonstrate the usage connecting to Microsoft Dataverse Web API within the context of solution leveraging the CoreEx library and dependency injection (DI).
The Dataverse connection string settings are required:
public class DemoSettings : SettingsBase
{
private readonly string UrlKey = "Url";
private readonly string ClientIdKey = "ClientId";
private readonly string CiientSecretKey = "ClientSecret";
private readonly string TenantIdKey = "TenantId";
/// <summary>
/// Initializes a new instance of the <see cref="DemoSettings"/> class.
/// </summary>
/// <param name="configuration">The <see cref="IConfiguration"/>.</param>
public DemoSettings(IConfiguration configuration) : base(configuration, "Demo") { }
/// <summary>
/// Gets the Dataverse connection string.
/// </summary>
public string DataverseConnectionString => GetRequiredValue<string>("ConnectionStrings__Dataverse");
/// <summary>
/// Gets the <see cref="DataverseSettings"/> from the <see cref="DataverseConnectionString"/>.
/// </summary>
public DataverseSettings DataverseConnectionSettings
{
get
{
var cs = DataverseConnectionString.Split(';').Select(s => s.Split('=')).ToDictionary(s => s[0].Trim(), s => s[1].Trim(), StringComparer.OrdinalIgnoreCase);
if (!cs.TryGetValue(UrlKey, out var url)) throw new InvalidOperationException($"The connection string is missing the '{UrlKey}' key.");
if (!cs.TryGetValue(ClientIdKey, out var clientId)) throw new InvalidOperationException($"The connection string is missing the '{ClientIdKey}' key.");
if (!cs.TryGetValue(CiientSecretKey, out var clientSecret)) throw new InvalidOperationException($"The connection string is missing the '{CiientSecretKey}' key.");
if (!cs.TryGetValue(TenantIdKey, out var tenantId)) throw new InvalidOperationException($"The connection string is missing the '{TenantIdKey}' key.");
return new DataverseSettings(url, clientId, clientSecret, tenantId);
}
}
/// <summary>
/// Gets the Dataverse OData endpoint from the <see cref="DataverseConnectionString"/>.
/// </summary>
public Uri DataverseODataEndpoint => new(DataverseConnectionSettings.Address, "/api/data/v9.2/");
/// <summary>
/// Represents the resuluting <see cref="DataverseConnectionSettings"/>.
/// </summary>
public class DataverseSettings
{
/// <summary>
/// Initializes a new instance of the <see cref="DataverseSettings"/> class.
/// </summary>
internal DataverseSettings(string url, string clientId, string clientSecret, string tenantId)
{
Address = new Uri(url);
ClientId = clientId;
ClientSecret = clientSecret;
TenantId = tenantId;
}
/// <summary>
/// Gets the address <see cref="Uri"/>.
/// </summary>
public Uri Address { get; }
/// <summary>
/// Gets the client identifier.
/// </summary>
public string ClientId { get; }
/// <summary>
/// Gets the client secret.
/// </summary>
public string ClientSecret { get; }
/// <summary>
/// Gets the tenant identifier.
/// </summary>
public string TenantId { get; }
}
}
The Dataverse connection string is stored in the appsettings.json
file:
{
"ConnectionStrings": {
"Dataverse": "Url=https://<your-tenant>.crm.dynamics.com;ClientId=<your-client-id>;ClientSecret=<your-client-secret>;TenantId=<your-tenant-id>"
}
}
The Dataverse authentication is handled by leveraging a DelegatingHandler
to perform the authentication (uses MSAL.NET), cache the token, and add the Authorization
header to each request.
public class DataverseAuthenticationHandler : DelegatingHandler
{
private readonly SemaphoreSlim _semaphore = new(1, 1);
private readonly SyncSettings _settings;
private AuthenticationResult? _authResult;
/// <summary>
/// Initializes a new instance of the <see cref="DataverseAuthenticationHandler"/> class.
/// </summary>
/// <param name="settings">The <see cref="SyncSettings"/>.</param>
public DataverseAuthenticationHandler(SyncSettings settings) => _settings = settings.ThrowIfNull();
/// <inheritdoc/>
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
// Verify and renew token if needed.
await VerifyAndRenewTokenAsync(cancellationToken).ConfigureAwait(false);
// Set the authorization bearer token.
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authResult!.AccessToken);
// Honor the commitment to keep calling down the chain.
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Verifies and renews the token if needed: first time, has expired, or will expire in less than 5 minutes - then get token and cache for improved performance.
/// </summary>
private async Task VerifyAndRenewTokenAsync(CancellationToken cancellationToken)
{
// First time, has expired, or will expire in less than 5 minutes, then get token - token cached for performance.
var expiryLimit = DateTimeOffset.UtcNow.AddMinutes(5);
if (_authResult == null || _authResult.ExpiresOn <= expiryLimit)
{
await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Recheck in case another thread has already renewed the token.
if (_authResult == null || _authResult.ExpiresOn <= expiryLimit)
{
var dcs = _settings.DataverseConnectionSettings;
var authority = new Uri($"https://login.microsoftonline.com/{dcs.TenantId}");
var app = ConfidentialClientApplicationBuilder
.Create(dcs.ClientId)
.WithClientSecret(dcs.ClientSecret)
.WithAuthority(authority)
.Build();
var scopes = new List<string> { new Uri(dcs.Address, "/.default").AbsoluteUri };
_authResult = await app.AcquireTokenForClient(scopes).ExecuteAsync(cancellationToken).ConfigureAwait(false);
}
}
finally
{
_semaphore.Release();
}
}
}
}
Extend to the ODataClient
to provide the specific Dataverse implementation. The ODataArgs
can be used to configure the ODataClient
to derive specific behavior where applicable.
using Soc = Simple.OData.Client;
public class DataverseClient : ODataClient
{
/// <summary>
/// Initializes a new instance of the <see cref="DataverseClient"/> class.
/// </summary>
/// <param name="client">The <see cref="Soc.ODataClient"/>.</param>
/// <param name="mapper">The <see cref="IMapper"/>.</param>
public DataverseClient(Soc.ODataClient client, IMapper mapper) : base(client, mapper)
{
Args = new ODataArgs { PreReadOnUpdate = false };
}
}
At application start up the dependency injection (DI) needs to be configured; comprised of the following:
- Register the
DataverseClient
as scoped; assumes that theIMapper
has also been configured. - Register the
Soc.ODataClient
as scoped; instantiates a newSoc.ODataClientSettings
with the namedHttpClient
. - Register the
DataverseAuthenticationHandler
as a singleton; to ensure the underlying token is used for all requests. - Register the
HttpClient
with a name of"dataverse"
; also configured with theDataverseAuthenticationHandler
.
// Configure the Dataverse required services.
Services
.AddScoped<DataverseClient>()
.AddScoped(sp =>
{
var hc = sp.GetRequiredService<IHttpClientFactory>().CreateClient("dataverse");
var socs = new Soc.ODataClientSettings(hc);
return new Soc.ODataClient(socs);
})
.AddSingleton<DataverseAuthenticationHandler>() // Singleton to ensure the underlying token is reused.
.AddHttpClient("dataverse", (sp, client) => client.BaseAddress = sp.GetRequiredService<SyncSettings>().DataverseODataEndpoint)
.AddHttpMessageHandler<DataverseAuthenticationHandler>();