Skip to content

panoramicdata/PanoramicData.OData.Client

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

48 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PanoramicData.OData.Client

Nuget Nuget License: MIT Codacy Badge

A lightweight, modern OData V4 client library for .NET 10.

What's New

See the CHANGELOG for a complete list of changes in each version.

OData V4 Feature Support

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

Installation

dotnet add package PanoramicData.OData.Client

Quick Start

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);

Entity Model Example

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

// 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);

Fluent Query Execution

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);

Raw OData Queries

// 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");

OData Functions and Actions

// 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" });

Logging with Dependency Injection

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);

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:

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<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

Configuration Options

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

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}");
}

Testing

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/";

Documentation

For detailed documentation on each feature, see the Documentation folder:

License

MIT License - see LICENSE file for details.

Contributing

Contributions are welcome! Please open an issue or submit a pull request.

About

A crazy-fast, MIT-licesed OData Client

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published