A 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.Clientusing 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:
- Querying Data - Filter, select, expand, order, page, search, aggregate
- CRUD Operations - Create, read, update, delete entities
- Batch Operations - Multiple operations in single request
- Singletons - Single-instance entities like /Me
- Media & Streams - Binary data and media entities
- Entity References - Managing relationships with $ref
- Delta Queries - Change tracking and synchronization
- Service Metadata - Discovery and schema information
- Functions & Actions - Custom operations
- Async Operations - Long-running operations
- Cross-Join - Combining multiple entity sets
- Open Types - Dynamic properties
- ETag & Concurrency - Optimistic concurrency control
MIT License - see LICENSE file for details.
Contributions are welcome! Please open an issue or submit a pull request.