A lightweight, modern OData V4 client library for .NET. Features include LINQ-like query builder, full CRUD support, automatic pagination, retry logic, and ILogger integration.
$ dotnet add package PanoramicData.OData.ClientA lightweight, modern OData V4 client library for .NET 10.
See the CHANGELOG for a complete list of changes in each version.
| Feature | Status | Documentation |
|---|---|---|
| Querying | ||
| $filter | ✅ Supported | Querying |
| $select | ✅ Supported | Querying |
| $expand | ✅ Supported | Querying |
| $orderby | ✅ Supported | Querying |
| $top / $skip | ✅ Supported | Querying |
| $count | ✅ Supported | Querying |
| $search | ✅ Supported | Querying |
| $apply (Aggregations) | ✅ Supported | Querying |
| $compute | ✅ Supported | Querying |
| Lambda operators (any/all) | ✅ Supported | Querying |
| Type casting (derived types) | ✅ Supported | Querying |
| CRUD Operations | ||
| Create (POST) | ✅ Supported | CRUD |
| Read (GET) | ✅ Supported | CRUD |
| Update (PATCH) | ✅ Supported | CRUD |
| Replace (PUT) | ✅ Supported | CRUD |
| Delete (DELETE) | ✅ Supported | CRUD |
| Batch Operations | ||
| Batch requests | ✅ Supported | Batch |
| Changesets (atomic) | ✅ Supported | Batch |
| Singleton Entities | ||
| Get singleton | ✅ Supported | Singletons |
| Update singleton | ✅ Supported | Singletons |
| Media Entities & Streams | ||
| Get stream ($value) | ✅ Supported | Streams |
| Set stream | ✅ Supported | Streams |
| Named stream properties | ✅ Supported | Streams |
| Entity References ($ref) | ||
| Add reference | ✅ Supported | References |
| Remove reference | ✅ Supported | References |
| Set reference | ✅ Supported | References |
| Delete reference | ✅ Supported | References |
| Delta Queries | ||
| Delta tracking | ✅ Supported | Delta |
| Deleted entities | ✅ Supported | Delta |
| Delta pagination | ✅ Supported | Delta |
| Service Metadata | ||
| $metadata | ✅ Supported | Metadata |
| Service document | ✅ Supported | Metadata |
| Functions & Actions | ||
| Bound functions | ✅ Supported | Functions & Actions |
| Unbound functions | ✅ Supported | Functions & Actions |
| Bound actions | ✅ Supported | Functions & Actions |
| Unbound actions | ✅ Supported | Functions & Actions |
| Async Operations | ||
| Prefer: respond-async | ✅ Supported | Async |
| Status polling | ✅ Supported | Async |
| Advanced Features | ||
| Cross-join ($crossjoin) | ✅ Supported | Cross-Join |
| Open types | ✅ Supported | Open Types |
| ETag concurrency | ✅ Supported | ETag & Concurrency |
| Server-driven paging | ✅ Supported | Querying |
| Retry logic | ✅ Supported | Configuration |
| Custom headers | ✅ Supported | Querying |
dotnet add package PanoramicData.OData.Client
using PanoramicData.OData.Client;
// Create the client
var client = new ODataClient(new ODataClientOptions
{
BaseUrl = "https://services.odata.org/V4/OData/OData.svc/",
ConfigureRequest = request =>
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "your-token");
}
});
// Query entities
var query = client.For<Product>("Products")
.Filter("Price gt 100")
.OrderBy("Name")
.Top(10);
var response = await client.GetAsync(query);
// Get all pages automatically
var allProducts = await client.GetAllAsync(query, cancellationToken);
// Get by key
var product = await client.GetByKeyAsync<Product, int>(123);
// Create
var newProduct = await client.CreateAsync("Products", new Product { Name = "Widget" });
// Update (PATCH)
var updated = await client.UpdateAsync<Product>("Products", 123, new { Price = 150.00 });
// Delete
await client.DeleteAsync("Products", 123);
using System.Text.Json.Serialization;
public class Product
{
[JsonPropertyName("ID")]
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public DateTimeOffset? ReleaseDate { get; set; }
public int? Rating { get; set; }
public decimal? Price { get; set; }
}
// Filtering with OData expressions
var query = client.For<Product>("Products")
.Filter("Rating gt 3")
.Top(3);
// Select specific fields
var query = client.For<Product>("Products")
.Select("ID,Name,Price")
.Top(3);
// Expand navigation properties
var query = client.For<Product>("Products")
.Expand("Category,Supplier");
// Ordering
var query = client.For<Product>("Products")
.OrderBy("Price desc")
.Top(5);
// Paging
var query = client.For<Product>("Products")
.Skip(20)
.Top(10)
.Count();
// Search
var query = client.For<Product>("Products")
.Search("widget");
// Custom headers per query
var query = client.For<Product>("Products")
.WithHeader("Prefer", "return=representation");
// Combine multiple options
var query = client.For<Product>("Products")
.Filter("Rating gt 3")
.Select("ID,Name,Price")
.OrderBy("Price desc")
.Top(10);
Execute queries directly from the query builder without needing to pass the query to a separate method:
// Get all matching entities
var products = await client.For<Product>("Products")
.Filter("Price gt 100")
.OrderBy("Name")
.GetAsync(cancellationToken);
// Get all pages automatically
var allProducts = await client.For<Product>("Products")
.Filter("Rating gt 3")
.GetAllAsync(cancellationToken);
// Get first or default
var cheapest = await client.For<Product>("Products")
.OrderBy("Price")
.GetFirstOrDefaultAsync(cancellationToken);
// Get single entity (throws if not exactly one)
var unique = await client.For<Product>("Products")
.Filter("Name eq 'SpecialWidget'")
.GetSingleAsync(cancellationToken);
// Get single or default (returns null if none, throws if multiple)
var maybeOne = await client.For<Product>("Products")
.Filter("ID eq 123")
.GetSingleOrDefaultAsync(cancellationToken);
// Get count
var count = await client.For<Product>("Products")
.Filter("Price gt 50")
.GetCountAsync(cancellationToken);
// Use raw filter strings for complex scenarios
var query = client.For<Product>("Products")
.Filter("contains(tolower(Name), 'widget')");
// Get raw JSON response
var json = await client.GetRawAsync("Products?$filter=Price gt 100");
// Call a function
var query = client.For<Product>("Products")
.Function("Microsoft.Dynamics.CRM.SearchProducts", new { SearchTerm = "widget" });
var result = await client.CallFunctionAsync<Product, List<Product>>(query);
// Call an action
var response = await client.CallActionAsync<OrderResult>(
"Orders(123)/Microsoft.Dynamics.CRM.Ship",
new { TrackingNumber = "ABC123" });
The client supports ILogger for detailed request/response logging:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using PanoramicData.OData.Client;
// Set up dependency injection with logging
var services = new ServiceCollection();
services.AddLogging(builder =>
{
builder
.SetMinimumLevel(LogLevel.Debug)
.AddSimpleConsole(options =>
{
options.IncludeScopes = true;
options.SingleLine = false;
options.TimestampFormat = "HH:mm:ss.fff ";
});
});
var serviceProvider = services.BuildServiceProvider();
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
// Create the ODataClient with logging enabled
var logger = loggerFactory.CreateLogger<ODataClient>();
var client = new ODataClient(new ODataClientOptions
{
BaseUrl = "https://services.odata.org/V4/OData.svc/",
Logger = logger,
RetryCount = 3,
RetryDelay = TimeSpan.FromMilliseconds(500)
});
// Now all requests will be logged with full details
var query = client.For<Product>("Products").Top(5);
var response = await client.GetAsync(query);
| Level | Information Logged |
|---|---|
Trace | Full HTTP traffic: request URL, method, all headers, request body, response status, response headers, response body |
Debug | Request URLs, methods, status codes, content lengths, parsed item counts |
Warning | Retry attempts for failed requests |
Error | Failed requests with response body |
To see complete request and response details including headers and body content, set the minimum log level to Trace:
services.AddLogging(builder =>
{
builder
.SetMinimumLevel(LogLevel.Trace) // Enable full HTTP traffic logging
.AddSimpleConsole();
});
Sample Trace output:
=== HTTP Request ===
GET https://api.example.com/Products?$top=5
--- Request Headers ---
Authorization: Bearer eyJ...
Accept: application/json
--- Request Body ---
(none for GET requests)
=== HTTP Response ===
Status: 200 OK
--- Response Headers ---
Content-Type: application/json; odata.metadata=minimal
OData-Version: 4.0
--- Response Body ---
{"@odata.context":"...","value":[{"ID":1,"Name":"Widget",...}]}
12:34:56.789 dbug: PanoramicData.OData.Client.ODataClient[0]
GetAsync<Product> - URL: Products?$top=5
12:34:56.890 dbug: PanoramicData.OData.Client.ODataClient[0]
CreateRequest - GET Products?$top=5
12:34:57.123 dbug: PanoramicData.OData.Client.ODataClient[0]
SendWithRetryAsync - Received OK from Products?$top=5
12:34:57.145 dbug: PanoramicData.OData.Client.ODataClient[0]
GetAsync<Product> - Response received, content length: 1234
12:34:57.156 dbug: PanoramicData.OData.Client.ODataClient[0]
GetAsync<Product> - Parsed 5 items from 'value' array
var client = new ODataClient(new ODataClientOptions
{
// Required: Base URL of the OData service
BaseUrl = "https://api.example.com/odata",
// Optional: Request timeout (default: 5 minutes)
Timeout = TimeSpan.FromMinutes(5),
// Optional: Retry configuration for transient failures
RetryCount = 3,
RetryDelay = TimeSpan.FromSeconds(1),
// Optional: Provide your own HttpClient
HttpClient = existingHttpClient,
// Optional: ILogger for debug logging
Logger = loggerInstance,
// Optional: Custom JSON serialization settings
JsonSerializerOptions = customOptions,
// Optional: Configure headers for every request
ConfigureRequest = request =>
{
request.Headers.Add("Custom-Header", "value");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "token");
}
});
try
{
var product = await client.GetByKeyAsync<Product, int>(999);
}
catch (ODataNotFoundException ex)
{
// 404 - Entity not found
Console.WriteLine($"Not found: {ex.RequestUrl}");
}
catch (ODataUnauthorizedException ex)
{
// 401 - Unauthorized
Console.WriteLine($"Unauthorized: {ex.ResponseBody}");
}
catch (ODataForbiddenException ex)
{
// 403 - Forbidden
Console.WriteLine($"Forbidden: {ex.ResponseBody}");
}
catch (ODataConcurrencyException ex)
{
// 412 - ETag mismatch
Console.WriteLine($"Concurrency conflict: {ex.RequestETag} vs {ex.CurrentETag}");
}
catch (ODataClientException ex)
{
// Other errors
Console.WriteLine($"Status: {ex.StatusCode}, Body: {ex.ResponseBody}");
}
The library can be tested against the public OData sample services:
// Read-only sample service
const string ODataV4ReadOnlyUri = "https://services.odata.org/V4/OData/OData.svc/";
// Read-write sample service (creates unique session)
const string ODataV4ReadWriteUri = "https://services.odata.org/V4/OData/%28S%28readwrite%29%29/OData.svc/";
// Northwind sample service
const string NorthwindV4ReadOnlyUri = "https://services.odata.org/V4/Northwind/Northwind.svc/";
// TripPin sample service
const string TripPinV4ReadWriteUri = "https://services.odata.org/V4/TripPinServiceRW/";
For detailed documentation on each feature, see the Documentation folder:
MIT License - see LICENSE file for details.
Contributions are welcome! Please open an issue or submit a pull request.