FlintsLabs.D365.ODataClient
1.2.27
dotnet add package FlintsLabs.D365.ODataClient --version 1.2.27
NuGet\Install-Package FlintsLabs.D365.ODataClient -Version 1.2.27
<PackageReference Include="FlintsLabs.D365.ODataClient" Version="1.2.27" />
<PackageVersion Include="FlintsLabs.D365.ODataClient" Version="1.2.27" />
<PackageReference Include="FlintsLabs.D365.ODataClient" />
paket add FlintsLabs.D365.ODataClient --version 1.2.27
#r "nuget: FlintsLabs.D365.ODataClient, 1.2.27"
#:package FlintsLabs.D365.ODataClient@1.2.27
#addin nuget:?package=FlintsLabs.D365.ODataClient&version=1.2.27
#tool nuget:?package=FlintsLabs.D365.ODataClient&version=1.2.27
FlintsLabs.D365.ODataClient
A fluent OData client for Microsoft Dynamics 365 Finance & Operations.
Features
- 🔗 Fluent API - Chainable query builder with IntelliSense support
- 🔍 LINQ Support - Write queries using lambda expressions
- ➕ Expand Support - Easily expand navigation properties (
query.Expand("Nav")orquery.Expand(x => x.Nav)) - 📨 Custom Headers - Add custom headers like
Preferto requests - 🏢 Cross-Company - Query across legal entities
- 🔐 Multi-Auth Support - Azure AD (Cloud), ADFS (On-Premise), and Dataverse
- 📦 CRUD Operations - Full Create, Read, Update, Delete support
- 🌐 Multi-Source - Connect to multiple D365 instances (F&O, Dataverse) simultaneously
Table of Contents
Installation
dotnet add package FlintsLabs.D365.ODataClient
Configuration
Option 1: Azure AD (Cloud D365)
// Program.cs - Fluent Builder using Enum
builder.Services.AddD365ODataClient(d365 =>
{
d365.UseAzureAD()
.WithClientId("your-client-id")
.WithClientSecret("your-client-secret")
.WithTenantId("your-tenant-id")
.WithResource("https://your-org.operations.dynamics.com");
});
// appsettings.json
{
"D365": {
"ClientId": "your-client-id",
"ClientSecret": "your-client-secret",
"TenantId": "your-tenant-id",
"Resource": "https://your-org.operations.dynamics.com"
}
}
// Or from configuration
builder.Services.AddD365ODataClient(builder.Configuration, "D365");
Option 2: ADFS (On-Premise D365)
// Program.cs - Fluent Builder for ADFS
builder.Services.AddD365ODataClient(d365 =>
{
d365.UseADFS()
.WithTokenEndpoint("https://fs.your-company.com/adfs/oauth2/token")
.WithClientId("your-client-id")
.WithClientSecret("your-client-secret")
.WithResource("https://ax.your-company.com")
.WithOrganizationUrl("https://ax.your-company.com/namespaces/AXSF/");
});
// appsettings.json for ADFS
{
"D365OnPrem": {
"TenantId": "adfs",
"TokenEndpoint": "https://fs.your-company.com/adfs/oauth2/token",
"ClientId": "your-client-id",
"ClientSecret": "your-client-secret",
"Resource": "https://ax.your-company.com",
"OrganizationUrl": "https://ax.your-company.com/namespaces/AXSF/",
"GrantType": "client_credentials"
}
}
// From configuration (auto-detects ADFS when TenantId="adfs" or TokenEndpoint is set)
builder.Services.AddD365ODataClient(builder.Configuration, "D365OnPrem");
Option 3: Microsoft Dataverse (CRM / Power Platform)
// Program.cs - Fluent Builder for Dataverse
builder.Services.AddD365ODataClient(D365ServiceScope.Dataverse, d365 =>
{
d365.WithClientId("your-client-id")
.WithClientSecret("your-client-secret")
.WithTenantId("your-tenant-id")
.WithResource("https://org.api.crm5.dynamics.com")
.WithOrganizationUrl("https://org.api.crm5.dynamics.com/api/data/v9.2/")
.WithScope("https://org.api.crm5.dynamics.com/.default")
.WithBooleanFormatting(D365BooleanFormatting.Literal);
});
// appsettings.json
{
"DataverseConfigs": {
"ClientId": "your-client-id",
"ClientSecret": "your-client-secret",
"TenantId": "your-tenant-id",
"Resource": "https://org.api.crm5.dynamics.com",
"OrganizationUrl": "https://org.api.crm5.dynamics.com/api/data/v9.2/",
"Scope": "https://org.api.crm5.dynamics.com/.default",
"BooleanFormatting": "Literal"
}
}
// From configuration
builder.Services.AddD365ODataClient(
D365ServiceScope.Dataverse,
builder.Configuration,
"DataverseConfigs");
Dataverse requires both Resource and OrganizationUrl:
- Resource = Used for authentication token (base domain only)
- OrganizationUrl = Used as API base URL (includes
/api/data/v9.2/)
If omitted, the library uses Resource + /data/ which is incorrect for Dataverse.
Boolean Formatting:
Dataverse uses standard true/false for booleans, while D365 F&O uses NoYes enum.
Use WithBooleanFormatting(D365BooleanFormatting.Literal) or set "BooleanFormatting": "Literal" in config for Dataverse.
Nullable Booleans (bool?):
When using GetValueOrDefault(), the library translates it to check against true.
| Expression | C# Value (null) |
C# Value (false) |
C# Value (true) |
|---|---|---|---|
x.Prop.GetValueOrDefault() |
false (Excludes) |
false (Excludes) |
true (Includes) |
!x.Prop.GetValueOrDefault() |
true (Includes) |
true (Includes) |
false (Excludes) |
x.Prop == false |
false (Excludes) |
true (Includes) |
false (Excludes) |
Note: null treats as false
Option 4: Multiple D365 Sources (Cloud + On-Premise)
// Program.cs - Named Services for multiple D365 sources
builder.Services.AddD365ODataClient(D365ServiceScope.Cloud, d365 =>
{
d365.UseAzureAD()
.WithClientId("cloud-client-id")
.WithClientSecret("cloud-secret")
.WithTenantId("cloud-tenant-id")
.WithResource("https://cloud.operations.dynamics.com");
});
builder.Services.AddD365ODataClient(D365ServiceScope.OnPrem, d365 =>
{
d365.UseADFS()
.WithTokenEndpoint("https://fs.company.com/adfs/oauth2/token")
.WithClientId("onprem-client-id")
.WithClientSecret("onprem-secret")
.WithResource("https://ax.company.com")
.WithOrganizationUrl("https://ax.company.com/namespaces/AXSF/");
});
// Or from configuration with named sections
builder.Services.AddD365ODataClient(D365ServiceScope.Cloud, builder.Configuration, "D365Cloud");
builder.Services.AddD365ODataClient(D365ServiceScope.OnPrem, builder.Configuration, "D365OnPrem");
Usage in ASP.NET Core
Quick Start (Single D365 Source)
Use this pattern when connecting to one D365 instance only.
Step 1: Configure appsettings.json
{
"D365": {
"ClientId": "your-client-id",
"ClientSecret": "your-client-secret",
"TenantId": "your-tenant-id",
"Resource": "https://your-org.operations.dynamics.com"
}
}
Step 2: Register in Program.cs
// Register D365 client (no name = "Default")
builder.Services.AddD365ODataClient(builder.Configuration, "D365");
Step 3: Inject ID365Service in Controller
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly ID365Service _d365;
// DI will inject ID365Service automatically
public ProductsController(ID365Service d365)
{
_d365 = d365;
}
[HttpGet]
public async Task<IActionResult> GetProducts()
{
var products = await _d365.Entity<Product>("ReleasedProductsV2")
.CrossCompany()
.Where(p => p.ItemNumber.StartsWith("A"))
.Take(10)
.ToListAsync();
return Ok(products);
}
// IN clause (multiple values) - auto-generates OR filter
[HttpGet("by-codes")]
public async Task<IActionResult> GetProductsByCodes([FromQuery] string[] codes)
{
var products = await _d365.Entity<Product>("ReleasedProductsV2")
.CrossCompany()
.Where(p => codes.Contains(p.ItemNumber)) // -> (ItemNumber eq 'A001' or ItemNumber eq 'A002' ...)
.ToListAsync();
return Ok(products);
}
}
Type-Safe Entity Names (User-Defined Enum)
Instead of using magic strings for entity names, you can define your own enum for type-safety and IntelliSense support.
Step 1: Define Your Entity Enum
using System.ComponentModel;
namespace MyApp.D365;
/// <summary>
/// Custom D365 Entity names for type-safe queries.
/// Use [Description] attribute to map to actual D365 entity name.
/// If no [Description], the enum member name is used.
/// </summary>
public enum D365Entity
{
// CUSTOMERS & VENDORS
[Description("CustomersV3")]
Customer,
[Description("VendorsV2")]
Vendor,
// PRODUCTS
[Description("ReleasedProductsV2")]
Product,
[Description("InventItemBarcodes")]
Barcode,
// ORDERS
[Description("SalesOrderHeadersV2")]
SalesOrderHeader,
[Description("SalesOrderLines")]
SalesOrderLine,
[Description("PurchaseOrderHeadersV2")]
PurchaseOrderHeader,
// FINANCE
[Description("LedgerJournalHeaders")]
JournalHeader,
// COMMON (no [Description] - uses enum name directly)
LegalEntities,
Companies,
Currencies
}
Step 2: Use Enum in Queries
// BEFORE: Magic string (error-prone)
var customers = await d365.Entity<Customer>("CustomersV3").ToListAsync();
// AFTER: Type-safe enum (recommended)
var customers = await d365.Entity<Customer>(D365Entity.Customer).ToListAsync();
// Works with all query methods
var products = await d365.Entity<Product>(D365Entity.Product)
.CrossCompany()
.Where(p => p.IsActive == true)
.Take(100)
.ToListAsync();
Resolution Priority
| Priority | Source | Example |
|---|---|---|
| 1️⃣ | [Description("...")] |
[Description("CustomersV3")] → "CustomersV3" |
| 2️⃣ | Enum member name | LegalEntities → "LegalEntities" |
Migration Guide (String → Enum)
Before (v1.2.15 and earlier):
public class ProductController : ControllerBase
{
private readonly ID365Service _d365;
public async Task<IActionResult> GetProducts()
{
var products = await _d365.Entity<Product>("ReleasedProductsV2")
.CrossCompany()
.ToListAsync();
return Ok(products);
}
}
After (v1.2.16+):
// 1. Create enum file: Enums/D365Entity.cs
public enum D365Entity
{
[Description("ReleasedProductsV2")]
Product
}
// 2. Update controller to use enum
public class ProductController : ControllerBase
{
private readonly ID365Service _d365;
public async Task<IActionResult> GetProducts()
{
var products = await _d365.Entity<Product>(D365Entity.Product) // <- Changed!
.CrossCompany()
.ToListAsync();
return Ok(products);
}
}
Benefits
- ✅ No typos - Compiler catches invalid entity names
- ✅ IntelliSense - Auto-complete entity names
- ✅ Centralized - All entity names in one file
- ✅ Refactor-safe - Rename enum updates all usages
- ✅ Documentation - XML comments on enum members
Performance
Internal Caching: The library automatically caches enum-to-string lookups using ConcurrentDictionary.
The first call uses reflection, subsequent calls are O(1) dictionary lookups.
| Method | First Call | Subsequent Calls | Type-Safety |
|---|---|---|---|
String .Entity<T>("CustomersV3") |
⚡ Fast | ⚡ Fast | ❌ |
Enum .Entity<T>(D365Entity.Customer) |
🐢 Reflection | ⚡ Cached | ✅ |
Recommendation: Use Enum for most cases (type-safety + cached performance). Use String only in extremely high-frequency loops where every nanosecond matters.
Multiple Enum Types
You can organize entities into multiple enum types (e.g., by module). Each enum type is cached separately:
// Sales module entities
public enum SalesEntity
{
[Description("SalesOrderHeadersV2")]
SalesOrder,
[Description("SalesOrderLines")]
SalesOrderLine
}
// Purchasing module entities
public enum PurchaseEntity
{
[Description("PurchaseOrderHeadersV2")]
PurchaseOrder
}
// Usage - both work correctly, no conflicts
var orders = await d365.Entity<SO>(SalesEntity.SalesOrder).ToListAsync();
var pos = await d365.Entity<PO>(PurchaseEntity.PurchaseOrder).ToListAsync();
Adding new enum values or creating new enum types requires no configuration - the library handles caching automatically.
Advanced (Multiple D365 Sources)
Use this pattern when connecting to multiple D365 instances (e.g., Cloud + On-Premise, Production + Sandbox).
Step 1: Configure appsettings.json with multiple sections
{
"D365Cloud": {
"ClientId": "cloud-client-id",
"ClientSecret": "cloud-secret",
"TenantId": "cloud-tenant-id",
"Resource": "https://cloud.operations.dynamics.com"
},
"D365OnPrem": {
"TenantId": "adfs",
"TokenEndpoint": "https://fs.company.com/adfs/oauth2/token",
"ClientId": "onprem-client-id",
"ClientSecret": "onprem-secret",
"Resource": "https://ax.company.com",
"OrganizationUrl": "https://ax.company.com/namespaces/AXSF/"
}
}
Step 2: Register with Names in Program.cs
⚠️ Important: The name you use here must match what you use in
GetService("name")later!
// Option A: Use Enum (recommended - prevents typos)
builder.Services.AddD365ODataClient(D365ServiceScope.Cloud, builder.Configuration, "D365Cloud");
builder.Services.AddD365ODataClient(D365ServiceScope.OnPrem, builder.Configuration, "D365OnPrem");
// Option B: Use custom string names (flexible)
builder.Services.AddD365ODataClient("Org1-Cloud", builder.Configuration, "D365Cloud");
builder.Services.AddD365ODataClient("Org2-OnPrem", builder.Configuration, "D365OnPrem");
Each client must have a unique name!
Registering the same name twice will throw an InvalidOperationException at startup:
D365 client 'Cloud' is already registered. Use a unique name for each client.
Step 3: Inject ID365ServiceFactory in Controller
[ApiController]
[Route("api/[controller]")]
public class SyncController : ControllerBase
{
private readonly ID365ServiceFactory _d365Factory;
// DI will inject the factory (not individual services)
public SyncController(ID365ServiceFactory d365Factory)
{
_d365Factory = d365Factory;
}
[HttpGet("cloud-products")]
public async Task<IActionResult> GetFromCloud()
{
// Get service by the SAME name used in Program.cs
var d365 = _d365Factory.GetService("Cloud"); // Matches D365ServiceScope.Cloud
var products = await d365.Entity<Product>("ReleasedProductsV2")
.CrossCompany()
.Take(10)
.ToListAsync();
return Ok(products);
}
[HttpGet("onprem-orders")]
public async Task<IActionResult> GetFromOnPrem()
{
var d365 = _d365Factory.GetService("OnPrem"); // Matches D365ServiceScope.OnPrem
var orders = await d365.Entity<SalesOrder>("SalesOrderHeadersV2")
.CrossCompany()
.Take(10)
.ToListAsync();
return Ok(orders);
}
}
Naming Convention Summary
| Registration Method | GetService() Call | Notes |
|---|---|---|
AddD365ODataClient(config, "D365") |
GetService() or GetService("Default") |
Default name |
AddD365ODataClient(D365ServiceScope.Cloud, ...) |
GetService("Cloud") |
Enum → String |
AddD365ODataClient(D365ServiceScope.OnPrem, ...) |
GetService("OnPrem") |
Enum → String |
AddD365ODataClient("MyCustomName", ...) |
GetService("MyCustomName") |
Custom string |
💡 Tip: Use
D365ServiceScopeenum to prevent typos. The enum values are:Default,Cloud,OnPrem,Dataverse
CRUD Operations
// Create
await _d365.Entity<Customer>("CustomersV3").AddAsync(newCustomer);
// Read
var customers = await _d365.Entity<Customer>("CustomersV3")
.CrossCompany()
.Where(c => c.CustomerAccount == "CUST001")
.ToListAsync();
// Update
await _d365.Entity<Customer>("CustomersV3")
.AddIdentity("CustomerAccount", "CUST001")
.AddIdentity("dataAreaId", "usmf")
.CrossCompany()
.UpdateAsync(new { CustomerName = "Updated Name" });
// Update by Key (Where + [OdataKey] attribute required)
await _d365.Entity<EgrHeadETHTable>("rvl_egrheadeths")
.Where(x => x.Id == headId)
.UpdateAsync(new { rvl_wmsstatus = false });
// Delete
await _d365.Entity<Customer>("CustomersV3")
.AddIdentity("CustomerAccount", "CUST001")
.AddIdentity("dataAreaId", "usmf")
.CrossCompany()
.DeleteAsync();
OdataKey Update/Delete Examples
1) Single Key
using FlintsLabs.D365.ODataClient.Attributes;
using System.Text.Json.Serialization;
public class EgrHeadETHTable
{
[OdataKey]
[JsonPropertyName("rvl_egrheadethid")]
public Guid Id { get; set; }
}
Update
await _d365.Entity<EgrHeadETHTable>("rvl_egrheadeths")
.Where(x => x.Id == headId)
.UpdateAsync(new { rvl_wmsstatus = false });
Delete
await _d365.Entity<EgrHeadETHTable>("rvl_egrheadeths")
.Where(x => x.Id == headId)
.DeleteAsync();
2) Composite Key
using FlintsLabs.D365.ODataClient.Attributes;
using System.Text.Json.Serialization;
public class SalesLine
{
[OdataKey]
[JsonPropertyName("dataAreaId")]
public string DataAreaId { get; set; } = "";
[OdataKey]
[JsonPropertyName("SalesId")]
public string SalesId { get; set; } = "";
[OdataKey]
[JsonPropertyName("LineNumber")]
public decimal LineNumber { get; set; }
}
Update (ต้องระบุ key ให้ครบ)
await _d365.Entity<SalesLine>("SalesOrderLines")
.Where(x => x.DataAreaId == "usmf" && x.SalesId == "SO-001" && x.LineNumber == 1m)
.UpdateAsync(new { QtyOrdered = 5 });
Delete (ต้องระบุ key ให้ครบ)
await _d365.Entity<SalesLine>("SalesOrderLines")
.Where(x => x.DataAreaId == "usmf" && x.SalesId == "SO-001" && x.LineNumber == 1m)
.DeleteAsync();
ถ้าไม่อยากใช้ [OdataKey] ยังสามารถใช้ AddIdentity(...) ได้เหมือนเดิม
API Reference
Query Methods
The non-generic Entity(string) / Entity(Enum) methods return D365Service and are deprecated.
Use Entity<T>(...) to get D365Query<T> instead.
| Method | Description |
|---|---|
Entity<T>(string entityName) |
Start a query for the specified entity |
CrossCompany() |
Enable cross-company query |
Where(Expression<Func<T, bool>>) |
Filter using LINQ expression |
Select(Expression<Func<T, object>>) |
Select specific properties |
OrderBy(x => x.Property) |
Sort ascending using LINQ expression |
OrderByDescending(x => x.Property) |
Sort descending using LINQ expression |
ThenBy(x => x.Property) |
Secondary sort ascending |
ThenByDescending(x => x.Property) |
Secondary sort descending |
OrderBy(string property, bool asc) |
Sort by property name (legacy) |
Skip(int count) |
Skip N records |
Take(int count) |
Take N records |
AddIdentity(string key, object value) |
Add entity key for updates |
PageSize(int size) |
Set page size for pagination |
Expand(string nav) |
Expand navigation property |
Expand(Expression<Func<T, object>>) |
Expand navigation property (lambda) |
AddHeader(string key, string value) |
Add custom request header |
Execute Methods
| Method | Description |
|---|---|
ToListAsync() |
Execute query and return list |
FirstOrDefaultAsync() |
Execute query and return first or null |
CountAsync() |
Get count of matching records |
AddAsync(T entity) |
Create new record |
UpdateAsync(T entity) |
Update existing record |
DeleteAsync() |
Delete record |
Requirements
- .NET 8.0 or later
- Microsoft Dynamics 365 Finance & Operations
- Azure AD: App Registration with D365 F&O API permissions
- ADFS: Native Application registered in ADFS
Development
Running Tests
This project includes both Integration Tests (xUnit) and an interactive Test Console.
Configuration:
- Rename
appsettings.example.jsontoappsettings.jsonin the test project. - Update with your real D365 credentials (this file is git-ignored).
- Rename
Run xUnit Tests (Automated):
dotnet testRun Test Console (Interactive):
cd FlintsLabs.D365.ODataClient.TestConsole dotnet run
Logging
All HTTP requests are logged with full URLs. Enable Debug level to see request bodies:
{
"Logging": {
"LogLevel": {
"Default": "Debug"
}
}
}
Example output:
info: D365 GET: https://org1.../data/ReleasedProductsV2?cross-company=true&$top=3
dbug: Request Body: {"SalesOrderNumber":"SO-001",...}
Verification (.NET 10)
This library is verified to support .NET 10. To verify compatibility:
dotnet test -f net10.0
License
MIT License - see LICENSE for details.
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.AspNetCore.WebUtilities (>= 8.0.11)
- Microsoft.Extensions.Configuration.Abstractions (>= 9.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Http (>= 9.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.0)
- Microsoft.Identity.Client (>= 4.67.2)
-
net8.0
- Microsoft.AspNetCore.WebUtilities (>= 8.0.11)
- Microsoft.Extensions.Configuration.Abstractions (>= 9.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Http (>= 9.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.0)
- Microsoft.Identity.Client (>= 4.67.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.2.27 | 173 | 2/9/2026 |
| 1.2.26 | 115 | 2/4/2026 |
| 1.2.25 | 128 | 1/26/2026 |
| 1.2.24 | 104 | 1/26/2026 |
| 1.2.23 | 101 | 1/26/2026 |
| 1.2.22 | 103 | 1/26/2026 |
| 1.2.21 | 111 | 1/25/2026 |
| 1.2.20 | 111 | 1/9/2026 |
| 1.2.19 | 107 | 1/9/2026 |
| 1.2.18 | 132 | 1/8/2026 |
| 1.2.17 | 113 | 1/8/2026 |
| 1.2.16 | 107 | 1/8/2026 |
| 1.2.15 | 104 | 1/8/2026 |
| 1.2.14 | 108 | 1/8/2026 |
| 1.2.13 | 111 | 1/7/2026 |
| 1.2.10 | 112 | 1/7/2026 |
| 1.2.9 | 104 | 1/7/2026 |
| 1.2.8 | 103 | 1/7/2026 |
| 1.2.7 | 112 | 1/7/2026 |
| 1.2.6 | 106 | 1/7/2026 |
v1.2.27: Added LINQ coalesce (??) support in OData translator with graceful error handling.
v1.2.26: Added [OdataKey] support for key-based Update/Delete via Where().
v1.2.25: Fixed BooleanFormatting config ignored in appsettings.json.
v1.2.24: Fix !Prop.GetValueOrDefault() generation ($filter=null).
v1.2.23: Configurable Boolean Formatting (NoYes vs Literal) for Dataverse support
v1.2.22: Expand improvements, Header support, and Logging. Fixed Expand(x=>x).
v1.2.21: LINQ OrderBy support (OrderBy, OrderByDescending, ThenBy, ThenByDescending)
v1.2.20: Cached JsonSerializerOptions (reduces allocations)
v1.2.19: Thread-safe registration (ConcurrentDictionary)
v1.2.18: Enum cache optimization (ConcurrentDictionary)
v1.2.17: Startup config validation (fail fast)
v1.2.16: Entity(Enum) for type-safe entity names
v1.2.15: Duplicate registration check
v1.2.14: List.Contains() support (IN clause)
v1.2.13: StringBuilder fix + logging docs
v1.2.12: Request body logging (LogDebug)
v1.2.11: Full URL logging
See CHANGELOG.md for full history.