https://github.com/panoramicdata/panoramicdata.odata.client
A crazy-fast, MIT-licensed OData Client
https://github.com/panoramicdata/panoramicdata.odata.client
Last synced: 2 months ago
JSON representation
A crazy-fast, MIT-licensed OData Client
- Host: GitHub
- URL: https://github.com/panoramicdata/panoramicdata.odata.client
- Owner: panoramicdata
- License: mit
- Created: 2025-12-15T13:15:56.000Z (6 months ago)
- Default Branch: main
- Last Pushed: 2026-03-20T02:16:38.000Z (3 months ago)
- Last Synced: 2026-03-27T08:24:24.298Z (3 months ago)
- Language: C#
- Size: 338 KB
- Stars: 9
- Watchers: 1
- Forks: 2
- Open Issues: 5
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
Awesome Lists containing this project
README
# PanoramicData.OData.Client
[](https://www.nuget.org/packages/PanoramicData.OData.Client/)
[](https://www.nuget.org/packages/PanoramicData.OData.Client/)
[](https://opensource.org/licenses/MIT)
[](https://app.codacy.com/gh/panoramicdata/PanoramicData.OData.Client/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
A lightweight, modern OData V4 client library for .NET 10.
## What's New
See the [CHANGELOG](CHANGELOG.md) for a complete list of changes in each version.
## OData V4 Feature Support
| Feature | Status | Documentation |
|---------|--------|---------------|
| **Querying** | | |
| $filter | ✅ Supported | [Querying](Documentation/querying.md#filtering-filter) |
| $select | ✅ Supported | [Querying](Documentation/querying.md#selecting-fields-select) |
| $expand | ✅ Supported | [Querying](Documentation/querying.md#expanding-related-entities-expand) |
| $orderby | ✅ Supported | [Querying](Documentation/querying.md#ordering-results-orderby) |
| $top / $skip | ✅ Supported | [Querying](Documentation/querying.md#paging-skip-top) |
| $count | ✅ Supported | [Querying](Documentation/querying.md#counting-results-count) |
| $search | ✅ Supported | [Querying](Documentation/querying.md#searching-search) |
| $apply (Aggregations) | ✅ Supported | [Querying](Documentation/querying.md#aggregations-apply) |
| $compute | ✅ Supported | [Querying](Documentation/querying.md#computed-properties-compute) |
| Lambda operators (any/all) | ✅ Supported | [Querying](Documentation/querying.md#filtering-filter) |
| Type casting (derived types) | ✅ Supported | [Querying](Documentation/querying.md#derived-types-type-casting) |
| **CRUD Operations** | | |
| Create (POST) | ✅ Supported | [CRUD](Documentation/crud.md#creating-entities) |
| Read (GET) | ✅ Supported | [CRUD](Documentation/crud.md#reading-entities) |
| Update (PATCH) | ✅ Supported | [CRUD](Documentation/crud.md#updating-entities-patch) |
| Replace (PUT) | ✅ Supported | [CRUD](Documentation/crud.md#replacing-entities-put) |
| Delete (DELETE) | ✅ Supported | [CRUD](Documentation/crud.md#deleting-entities) |
| **Batch Operations** | | |
| Batch requests | ✅ Supported | [Batch](Documentation/batch.md#creating-a-batch) |
| Changesets (atomic) | ✅ Supported | [Batch](Documentation/batch.md#changesets-atomic-transactions) |
| **Singleton Entities** | | |
| Get singleton | ✅ Supported | [Singletons](Documentation/singletons.md#getting-a-singleton) |
| Update singleton | ✅ Supported | [Singletons](Documentation/singletons.md#updating-a-singleton) |
| **Media Entities & Streams** | | |
| Get stream ($value) | ✅ Supported | [Streams](Documentation/streams.md#media-entities) |
| Set stream | ✅ Supported | [Streams](Documentation/streams.md#setting-stream-content) |
| Named stream properties | ✅ Supported | [Streams](Documentation/streams.md#named-stream-properties) |
| **Entity References ($ref)** | | |
| Add reference | ✅ Supported | [References](Documentation/references.md#adding-references-collection) |
| Remove reference | ✅ Supported | [References](Documentation/references.md#removing-references-collection) |
| Set reference | ✅ Supported | [References](Documentation/references.md#setting-references-single-valued) |
| Delete reference | ✅ Supported | [References](Documentation/references.md#deleting-references-single-valued) |
| **Delta Queries** | | |
| Delta tracking | ✅ Supported | [Delta](Documentation/delta.md#overview) |
| Deleted entities | ✅ Supported | [Delta](Documentation/delta.md#understanding-delta-responses) |
| Delta pagination | ✅ Supported | [Delta](Documentation/delta.md#getting-changes) |
| **Service Metadata** | | |
| $metadata | ✅ Supported | [Metadata](Documentation/metadata.md#retrieving-metadata) |
| Service document | ✅ Supported | [Metadata](Documentation/metadata.md#service-document) |
| **Functions & Actions** | | |
| Bound functions | ✅ Supported | [Functions & Actions](Documentation/functions-actions.md#bound-functions-entity-set) |
| Unbound functions | ✅ Supported | [Functions & Actions](Documentation/functions-actions.md#unbound-functions) |
| Bound actions | ✅ Supported | [Functions & Actions](Documentation/functions-actions.md#calling-actions) |
| Unbound actions | ✅ Supported | [Functions & Actions](Documentation/functions-actions.md#unbound-action) |
| **Async Operations** | | |
| Prefer: respond-async | ✅ Supported | [Async](Documentation/async-operations.md#async-action-calls) |
| Status polling | ✅ Supported | [Async](Documentation/async-operations.md#polling-for-completion) |
| **Advanced Features** | | |
| Cross-join ($crossjoin) | ✅ Supported | [Cross-Join](Documentation/cross-join.md#overview) |
| Open types | ✅ Supported | [Open Types](Documentation/open-types.md#overview) |
| ETag concurrency | ✅ Supported | [ETag & Concurrency](Documentation/etag-concurrency.md#overview) |
| Server-driven paging | ✅ Supported | [Querying](Documentation/querying.md#server-driven-paging) |
| Retry logic | ✅ Supported | [Configuration](#configuration-options) |
| Custom headers | ✅ Supported | [Querying](Documentation/querying.md#custom-headers) |
## Installation
```bash
dotnet add package PanoramicData.OData.Client
```
## Quick Start
```csharp
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("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(123);
// Create
var newProduct = await client.CreateAsync("Products", new Product { Name = "Widget" });
// Update (PATCH)
var updated = await client.UpdateAsync("Products", 123, new { Price = 150.00 });
// Delete
await client.DeleteAsync("Products", 123);
```
## Entity Model Example
```csharp
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; }
}
```
## Query Builder Features
```csharp
// Filtering with OData expressions
var query = client.For("Products")
.Filter("Rating gt 3")
.Top(3);
// Select specific fields
var query = client.For("Products")
.Select("ID,Name,Price")
.Top(3);
// Expand navigation properties
var query = client.For("Products")
.Expand("Category,Supplier");
// Ordering
var query = client.For("Products")
.OrderBy("Price desc")
.Top(5);
// Paging
var query = client.For("Products")
.Skip(20)
.Top(10)
.Count();
// Search
var query = client.For("Products")
.Search("widget");
// Custom headers per query
var query = client.For("Products")
.WithHeader("Prefer", "return=representation");
// Combine multiple options
var query = client.For("Products")
.Filter("Rating gt 3")
.Select("ID,Name,Price")
.OrderBy("Price desc")
.Top(10);
```
## Fluent Query Execution
Execute queries directly from the query builder without needing to pass the query to a separate method:
```csharp
// Get all matching entities
var products = await client.For("Products")
.Filter("Price gt 100")
.OrderBy("Name")
.GetAsync(cancellationToken);
// Get all pages automatically
var allProducts = await client.For("Products")
.Filter("Rating gt 3")
.GetAllAsync(cancellationToken);
// Get first or default
var cheapest = await client.For("Products")
.OrderBy("Price")
.GetFirstOrDefaultAsync(cancellationToken);
// Get single entity (throws if not exactly one)
var unique = await client.For("Products")
.Filter("Name eq 'SpecialWidget'")
.GetSingleAsync(cancellationToken);
// Get single or default (returns null if none, throws if multiple)
var maybeOne = await client.For("Products")
.Filter("ID eq 123")
.GetSingleOrDefaultAsync(cancellationToken);
// Get count
var count = await client.For("Products")
.Filter("Price gt 50")
.GetCountAsync(cancellationToken);
```
## Raw OData Queries
```csharp
// Use raw filter strings for complex scenarios
var query = client.For("Products")
.Filter("contains(tolower(Name), 'widget')");
// Get raw JSON response
var json = await client.GetRawAsync("Products?$filter=Price gt 100");
```
## OData Functions and Actions
```csharp
// Call a function
var query = client.For("Products")
.Function("Microsoft.Dynamics.CRM.SearchProducts", new { SearchTerm = "widget" });
var result = await client.CallFunctionAsync>(query);
// Call an action
var response = await client.CallActionAsync(
"Orders(123)/Microsoft.Dynamics.CRM.Ship",
new { TrackingNumber = "ABC123" });
```
## Logging with Dependency Injection
The client supports `ILogger` for detailed request/response logging:
```csharp
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();
// Create the ODataClient with logging enabled
var logger = loggerFactory.CreateLogger();
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("Products").Top(5);
var response = await client.GetAsync(query);
```
### Logging Levels
| 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 |
### Full HTTP Traffic Logging (Trace Level)
To see complete request and response details including headers and body content, set the minimum log level to `Trace`:
```csharp
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",...}]}
```
### Sample Debug Log Output
```
12:34:56.789 dbug: PanoramicData.OData.Client.ODataClient[0]
GetAsync - 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 - Response received, content length: 1234
12:34:57.156 dbug: PanoramicData.OData.Client.ODataClient[0]
GetAsync - Parsed 5 items from 'value' array
```
## Configuration Options
```csharp
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");
}
});
```
## Exception Handling
```csharp
try
{
var product = await client.GetByKeyAsync(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}");
}
```
## Testing
The library can be tested against the public OData sample services:
```csharp
// 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/";
```
## Documentation
For detailed documentation on each feature, see the Documentation folder:
- [Querying Data](Documentation/querying.md) - Filter, select, expand, order, page, search, aggregate
- [CRUD Operations](Documentation/crud.md) - Create, read, update, delete entities
- [Batch Operations](Documentation/batch.md) - Multiple operations in single request
- [Singletons](Documentation/singletons.md) - Single-instance entities like /Me
- [Media & Streams](Documentation/streams.md) - Binary data and media entities
- [Entity References](Documentation/references.md) - Managing relationships with $ref
- [Delta Queries](Documentation/delta.md) - Change tracking and synchronization
- [Service Metadata](Documentation/metadata.md) - Discovery and schema information
- [Functions & Actions](Documentation/functions-actions.md) - Custom operations
- [Async Operations](Documentation/async-operations.md) - Long-running operations
- [Cross-Join](Documentation/cross-join.md) - Combining multiple entity sets
- [Open Types](Documentation/open-types.md) - Dynamic properties
- [ETag & Concurrency](Documentation/etag-concurrency.md) - Optimistic concurrency control
## License
MIT License - see LICENSE file for details.
## Contributing
Contributions are welcome! Please open an issue or submit a pull request.