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 |
| Navigation properties | ✅ Supported | NavigateTo |
| Vendor query options | ✅ Supported | QueryOptions |
dotnet add package PanoramicData.OData.ClientusingPanoramicData.OData.Client;// Create the clientvarclient=newODataClient(newODataClientOptions{BaseUrl="https://services.odata.org/V4/OData/OData.svc/",ConfigureRequest= request =>{request.Headers.Authorization=newAuthenticationHeaderValue("Bearer","your-token");}});// Query entitiesvarquery=client.For<Product>("Products").Filter("Price gt 100").OrderBy("Name").Top(10);varresponse=awaitclient.GetAsync(query);// Get all pages automaticallyvarallProducts=awaitclient.GetAllAsync(query,cancellationToken);// Get by key (throws ODataNotFoundException if not found)varproduct=awaitclient.GetByKeyAsync<Product,int>(123);// Get by key - returns null if not foundvarproduct=awaitclient.GetByKeyOrDefaultAsync<Product,int>(123);// CreatevarnewProduct=awaitclient.CreateAsync("Products",newProduct{Name="Widget"});// Update (PATCH)varupdated=awaitclient.UpdateAsync<Product>("Products",123,new{Price=150.00});// Deleteawaitclient.DeleteAsync("Products",123);usingSystem.Text.Json.Serialization;publicclassProduct{[JsonPropertyName("ID")]publicintId{get;set;}publicstringName{get;set;}=string.Empty;publicstring?Description{get;set;}publicDateTimeOffset?ReleaseDate{get;set;}publicint?Rating{get;set;}publicdecimal?Price{get;set;}}// Filtering with OData expressionsvarquery=client.For<Product>("Products").Filter("Rating gt 3").Top(3);// Select specific fieldsvarquery=client.For<Product>("Products").Select("ID,Name,Price").Top(3);// Expand navigation propertiesvarquery=client.For<Product>("Products").Expand("Category,Supplier");// Orderingvarquery=client.For<Product>("Products").OrderBy("Price desc").Top(5);// Pagingvarquery=client.For<Product>("Products").Skip(20).Top(10).Count();// Searchvarquery=client.For<Product>("Products").Search("widget");// Custom headers per queryvarquery=client.For<Product>("Products").WithHeader("Prefer","return=representation");// Combine multiple optionsvarquery=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 entitiesvarproducts=awaitclient.For<Product>("Products").Filter("Price gt 100").OrderBy("Name").GetAsync(cancellationToken);// Get all pages automaticallyvarallProducts=awaitclient.For<Product>("Products").Filter("Rating gt 3").GetAllAsync(cancellationToken);// Get first or defaultvarcheapest=awaitclient.For<Product>("Products").OrderBy("Price").GetFirstOrDefaultAsync(cancellationToken);// Get single entity (throws if not exactly one)varunique=awaitclient.For<Product>("Products").Filter("Name eq 'SpecialWidget'").GetSingleAsync(cancellationToken);// Get single or default (returns null if none, throws if multiple)varmaybeOne=awaitclient.For<Product>("Products").Filter("ID eq 123").GetSingleOrDefaultAsync(cancellationToken);// Get countvarcount=awaitclient.For<Product>("Products").Filter("Price gt 50").GetCountAsync(cancellationToken);Navigate from a keyed entity to a dependent collection using NavigateTo, then use As<T>() to type the result:
// Produces: Mailboxes('user@example.com')/MailboxPermissionsvarpermissions=awaitclient.For<Mailbox>().Key("user@example.com").NavigateTo(x =>x.MailboxPermissions).As<MailboxPermission>().GetAsync(cancellationToken);// Or use the typed string overload when you know the target type staticallyvarpermissions=awaitclient.For<Mailbox>().Key("user@example.com").NavigateTo<MailboxPermission>("MailboxPermissions").GetAsync(cancellationToken);Append raw, non-standard query parameters verbatim using QueryOptions. Values are not quoted or URL-encoded, matching Simple.OData.Client's string overload:
// Produces: ...&PropertySet=Minimum,AddressListvarpropertySets=string.Join(",",new[]{"Minimum","AddressList"});varmailboxes=awaitclient.For<Mailbox>().Select(m =>new{m.UserPrincipalName,m.Alias}).QueryOptions($"PropertySet={propertySets}").Filter(m =>m.RecipientTypeDetails=="SharedMailbox").GetAsync(cancellationToken);// Use raw filter strings for complex scenariosvarquery=client.For<Product>("Products").Filter("contains(tolower(Name), 'widget')");// Get raw JSON responsevarjson=awaitclient.GetRawAsync("Products?$filter=Price gt 100");// Call a functionvarquery=client.For<Product>("Products").Function("Microsoft.Dynamics.CRM.SearchProducts",new{SearchTerm="widget"});varresult=awaitclient.CallFunctionAsync<Product,List<Product>>(query);// Call an actionvarresponse=awaitclient.CallActionAsync<OrderResult>("Orders(123)/Microsoft.Dynamics.CRM.Ship",new{TrackingNumber="ABC123"});The client supports ILogger for detailed request/response logging:
usingMicrosoft.Extensions.DependencyInjection;usingMicrosoft.Extensions.Logging;usingPanoramicData.OData.Client;// Set up dependency injection with loggingvarservices=newServiceCollection();services.AddLogging(builder =>{builder.SetMinimumLevel(LogLevel.Debug).AddSimpleConsole(options =>{options.IncludeScopes=true;options.SingleLine=false;options.TimestampFormat="HH:mm:ss.fff ";});});varserviceProvider=services.BuildServiceProvider();varloggerFactory=serviceProvider.GetRequiredService<ILoggerFactory>();// Create the ODataClient with logging enabledvarlogger=loggerFactory.CreateLogger<ODataClient>();varclient=newODataClient(newODataClientOptions{BaseUrl="https://services.odata.org/V4/OData.svc/",Logger=logger,RetryCount=3,RetryDelay=TimeSpan.FromMilliseconds(500)});// Now all requests will be logged with full detailsvarquery=client.For<Product>("Products").Top(5);varresponse=awaitclient.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
varclient=newODataClient(newODataClientOptions{// Required: Base URL of the OData serviceBaseUrl="https://api.example.com/odata",// Optional: Request timeout (default: 5 minutes)Timeout=TimeSpan.FromMinutes(5),// Optional: Retry configuration for transient failures (408, 429 and 5xx)RetryCount=3,RetryDelay=TimeSpan.FromSeconds(1),// Optional: upper bound on a server-supplied Retry-After header, which is honoured in// preference to RetryDelay. TimeSpan.Zero ignores Retry-After entirely. (default: 30 seconds)MaximumRetryAfterDelay=TimeSpan.FromSeconds(30),// Optional: Provide your own HttpClientHttpClient=existingHttpClient,// Optional: ILogger for debug loggingLogger=loggerInstance,// Optional: Custom JSON serialization settingsJsonSerializerOptions=customOptions,// Optional: Configure headers for every requestConfigureRequest= request =>{request.Headers.Add("Custom-Header","value");request.Headers.Authorization=newAuthenticationHeaderValue("Bearer","token");},// Optional: Return null instead of throwing ODataNotFoundException on 404IgnoreResourceNotFoundException=true});try{varproduct=awaitclient.GetByKeyAsync<Product,int>(999);}catch(ODataNotFoundExceptionex){// 404 - Entity not foundConsole.WriteLine($"Not found: {ex.RequestUrl}");}catch(ODataUnauthorizedExceptionex){// 401 - UnauthorizedConsole.WriteLine($"Unauthorized: {ex.ResponseBody}");}catch(ODataForbiddenExceptionex){// 403 - ForbiddenConsole.WriteLine($"Forbidden: {ex.ResponseBody}");}catch(ODataConcurrencyExceptionex){// 412 - ETag mismatchConsole.WriteLine($"Concurrency conflict: {ex.RequestETag} vs {ex.CurrentETag}");}catch(ODataClientExceptionex){// Other errorsConsole.WriteLine($"Status: {ex.StatusCode}, Body: {ex.ResponseBody}");}The library can be tested against the public OData sample services:
// Read-only sample serviceconststringODataV4ReadOnlyUri="https://services.odata.org/V4/OData/OData.svc/";// Read-write sample service (creates unique session)conststringODataV4ReadWriteUri="https://services.odata.org/V4/OData/%28S%28readwrite%29%29/OData.svc/";// Northwind sample serviceconststringNorthwindV4ReadOnlyUri="https://services.odata.org/V4/Northwind/Northwind.svc/";// TripPin sample serviceconststringTripPinV4ReadWriteUri="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.