An open API service indexing awesome lists of open source software.

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

Awesome Lists containing this project

README

          

# PanoramicData.OData.Client

[![Nuget](https://img.shields.io/nuget/v/PanoramicData.OData.Client)](https://www.nuget.org/packages/PanoramicData.OData.Client/)
[![Nuget](https://img.shields.io/nuget/dt/PanoramicData.OData.Client)](https://www.nuget.org/packages/PanoramicData.OData.Client/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/ee4c431534a0455baf5733c36aa87a45)](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.