Skip to content

Latest commit

 

History

History

CoreEx.OData

CoreEx

The CoreEx.OData namespace provides extended OData v4 support leveraging the Simple.OData.Client open-source capabilities.


Motivation

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.


ODataClient

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.


Requirements

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 of IIdentifier or IPrimaryKey.
  • 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.


CRUD capabilities

The IOData and corresponding ODataClient provide the base CRUD capabilities as follows:


Query (read)

A query is actioned using the ODataQuery which is obstensibly 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.

Get (read)

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.


Create

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.


Update

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.


Delete

Deletes (DeleteAsync or DeleteWithResultAsync) the entity. Uses Simple.OData.Client to delete.


Untyped

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

Usage

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).


Settings

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>"
  }
}

Authentication

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();
            }
        }
    }
}

Dataverse Client

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 };
    }
}

Registration

At application start up the dependency injection (DI) needs to be configured; comprised of the following:

  • Register the DataverseClient as scoped; assumes that the IMapper has also been configured.
  • Register the Soc.ODataClient as scoped; instantiates a new Soc.ODataClientSettings with the named HttpClient.
  • 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 the DataverseAuthenticationHandler.
// 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>();