Uniphar.Sap.OData.Client
9.4.0
dotnet add package Uniphar.Sap.OData.Client --version 9.4.0
NuGet\Install-Package Uniphar.Sap.OData.Client -Version 9.4.0
<PackageReference Include="Uniphar.Sap.OData.Client" Version="9.4.0" />
<PackageVersion Include="Uniphar.Sap.OData.Client" Version="9.4.0" />
<PackageReference Include="Uniphar.Sap.OData.Client" />
paket add Uniphar.Sap.OData.Client --version 9.4.0
#r "nuget: Uniphar.Sap.OData.Client, 9.4.0"
#:package Uniphar.Sap.OData.Client@9.4.0
#addin nuget:?package=Uniphar.Sap.OData.Client&version=9.4.0
#tool nuget:?package=Uniphar.Sap.OData.Client&version=9.4.0
sap-odata-client
NuGet package repository for Sap OData Client connection
Purpose
This library is for making calls to SAP (running in AZ). Calls to SAP require a base url. Full path will be decorated with the namespace of the service we call.
Calls to the SAP instance requires authentication with Basic auth - (username and password). These are stored in a
secret storage and you do not need to define these. The secret storage can be AZ Keyvault, AWS Secrets Manager or
others. Any token expiry is automatically handled by the SAP client and is not something to be concerned with either.
Custom Events
The library also publishes some custom telemetry events. This is because there has been several issues with the SAP Rise environments. We publish the following events
| Event | Description | Remarks |
|---|---|---|
| SapTokenExpiredEvent | When x-csrf-token has expired, and we get a 403 back when calling the SAP Rise instance |
|
| SapTokenUnauthorizedFetchEvent | When requesting a new x-csrf-token SAP returns a 401 instead of the expected 403 |
The system will throw an UnauthorizedAccessException |
| SapTokenRefreshedFetchEvent | When requesting a new x-csrf-token, and SAP returns anything but 401, we notify it was part of the Response headers |
|
| SapTokenNotFoundEvent | When requesting a new x-csrf-token, and SAP returns anything but 401. However, response headers do not contain x-csrf-token |
Setup your project to use SapClient package
When using this NuGet package you need to setup a few things in your project
SapClientOptions
| Property | IsRequired | Description | Source |
|---|---|---|---|
| UserName | True | The SAP user account name | KeyVault |
| Password | True | The SAP user account password | KeyVault |
| FetchTokenStatusCodeTriggers | False | The set of HttpStatus codes that should trigger renewal of sap token fetching. Default is 401. | appsettings |
The secrets name follow the patter SapClientOptions--{sapApp}--{UserName|Password}:
SapClientOptions--EWM--UserName
SapClientOptions--EWM--Password
SapClientOptions--S4--UserName
SapClientOptions--S4--Password
NOTE: :
FetchTokenStatusCodeTriggersis a list of HttpCodes we test against to trigger fetching a new token. We predefine the status code to check for asForbidden (403). If the issue we encountered before (returning401instead of403) starts again, you need to update the appsettings.json to handle both status codes. You should do that in the project that consumes this NuGet package.Significant changes with version 2.0.2
UseHttpClientHandlerandPooledConnectionLifetimehave been retired with version 2.0.2, as we now use short-living http client instances created by HttpClientFactory. These are no longer part ofSapClientOptionseither.
Client V2 (from 8.2.0+)
Registation
// Programc.s
builder.Configuration.AddAzureKeyVault(
new Uri(kvUri), tokenCredential,
new AzureKeyVaultConfigurationOptions
{
ReloadInterval = TimeSpan.FromMinutes(30),
Manager = new SapClientKeyVaultSecretManager("S4") // or "EWM"
});
builder.AddSapClient()
Usage
using ISapClient = Uniphar.Sap.OData.Client.V2.ISapClient;
public class MyService(ISapClient client)
The methods available are:
GetAsync<T>PostAsync<T>PutAsync<T>ExecuteBatchQueryAsync<T>
They return SapResponse<T> which contains:
bool Successint StatusCodestring? ErrorT Data
If nothing is returned from sap, use
<VoidResponse>as generic type to skip deserialization.
Client V1 (old)
Program.cs
Add the following to Program.cs
Use
SapClientKeyVaultSecretManagerto map specific credentials to the base configurationbuilder.Configuration.AddAzureKeyVault( new Uri(kvUri), tokenCredential, new AzureKeyVaultConfigurationOptions { ReloadInterval = TimeSpan.FromMinutes(30), Manager = new SapClientKeyVaultSecretManager("S4") // or "EWM" });Bind
SapClientOptionsso it can used inOptionspattern:builder.Services.Configure<SapClientOptions>( builder.Configuration.GetSection(nameof(SapClientOptions)));Register a singleton instance of
HttpClient, asSapClientmakes calls to SAP over HTTP:// If we need to use NullProxy if (bool.Parse(builder.Configuration["InstanceConfig:EnableNullProxy"]!)) { builder.Services.AddTransient<ISapClient, NullSapClient>(); } else { builder.Services.AddTransient<ISapClient, SapClient>(); builder.Services.AddHttpClient(nameof(SapClient), client => { client.BaseAddress = new Uri(builder.Configuration["SapClientOptions:BaseUrl"]!); client.Timeout = TimeSpan.Parse(builder.Configuration["SapClientOptions:Timeout"]!); }); }NOTE: : In the example above we have registered a named client as we intend to use same configuration for all delegated clients when making request to SAP.
Register Memory Cache:
services.AddMemoryCache(); return services;
Using SapClient library
We have a couple of predefined (and custom) JsonSerialisers we need to use when calling SAP. This is handled by class
SapJsonSerializer. It exposes a static method:
public static JsonSerializerOptions GetJsonSerializerOptions()
{
// preliminary code logic
jsonSerializerOptions.Converters.Add(new SapDateTimeConverter());
jsonSerializerOptions.Converters.Add(new SapDateTimeOffsetConverter());
jsonSerializerOptions.Converters.Add(new SapDecimalConverter());
return jsonSerializerOptions;
}
The SAP client package exposes three methods
Task<ContentResult> GetAsync<T>(string path, CancellationToken cancellationToken)
Task<ContentResult> PostAsync<T>(string path, T message, CancellationToken cancellationToken)
Task<ContentResult> PutAsync<T>(string path, T? body, CancellationToken cancellationToken)
Both methods return a ContentResult, which means we can handle based on the Http status code in the response.
Example usage:
public async Task<ActionResult<SalesOrderResponse>> CreateToDoList([FromBody] CreateToDoList createToDoList, CancellationToken cancellationToken)
{
// Call SAP Client to get Serializer Options
var serializerOptions = SapJsonSerializer.GetJsonSerializerOptions();
// Make call to SAP Client POST method
var createdTodoListContentResult = await _sapClient.PostAsync("TO_DO_LIST_API", createToDoList.MapToSapToDoListRequest(), cancellationToken);
switch(createdTodoListContentResult.StatusCode)
{
case 201:
var createdSapTodoListResult = JsonSerializer.Deserialize<OData<ToDoList>>(createdTodoListContentResult.Content!, serializerOptions);
// Make call to SAP Client GET method
var sapToDoListWithItems = await _sapClient.GetAsync<OData<ToDoList>>(
$"TO_DO_LIST_API/TO_DO_LIST_ITEM('{createdSapTodoListResult!.Data!.Id}')?$expand=to_Item", cancellationToken);
var sapToDoList = JsonSerializer.Deserialize<OData<ToDoList>>(sapToDoListWithItems.Content!, serializerOptions);
var newSapToDoList = sapToDoList!.Data!.MapToSapToDoListResponse();
return CreatedAtAction(nameof(CreateToDoList), new { id = newSalesOrder.Id }, newSalesOrder);
case 400:
var sapErrorResponse = JsonSerializer.Deserialize<SapErrorResponse>(createdTodoListContentResult.Content!, options);
return BadRequest(sapErrorResponse!.MapToProblemDetails());
default:
return new StatusCodeResult((int)createdTodoListContentResult.StatusCode!);
}
}
OData Query Builder
The package includes a fluent ODataQueryBuilder for constructing OData query URLs with automatic input sanitization. All string values passed into filter expressions are escaped to prevent OData injection — developers never need to remember to escape manually.
Basic Usage
using Uniphar.Sap.OData.Client;
var url = new ODataQueryBuilder("API_SALES_ORDER_SRV/A_SalesOrder")
.Filter(f => f
.Eq("SoldToParty", customerNumber)
.Eq("SalesOrganization", salesOrg)
.Eq("DistributionChannel", "10")
.Ge("CreationDate", fromDate)
.Le("CreationDate", toDate))
.Select("SalesOrder", "SalesOrderType", "CreationDate")
.SkipToken(skip)
.Build();
var response = await sapClient.GetAsync<NavigationList<A_SalesOrderType>>(url, cancellationToken);
Filter Operations
.Filter(f => f
// String equality (value is auto-escaped)
.Eq("Field", "O'Brien") // Field eq 'O''Brien'
// Numeric equality
.Eq("Count", 42) // Count eq 42
// DateTime / DateTimeOffset comparisons
.Ge("CreationDate", fromDate) // CreationDate ge datetime'2024-01-01T00:00:00'
.Le("CreationDate", toDate) // CreationDate le datetime'2024-12-31T23:59:59'
// Conditional filter (only added when condition is true)
.EqIf(hasReference, "Reference", () => reference)
// IN-style filter — SAP OData V2 does not support the `in` operator,
// so .In() expands to a parenthesized group of `or` expressions.
.In("Type", "Y100", "Y150", "Y251") // (Type eq 'Y100' or Type eq 'Y150' or Type eq 'Y251')
// Grouped sub-expressions
.Group(g => g.Eq("A", "1").Eq("B", "2")) // (A eq '1' and B eq '2')
.Or(o => o.Eq("X", "1").Eq("X", "2")) // (X eq '1' or X eq '2')
// Raw expression (no auto-escaping — use for complex filters)
.Raw("substringof('abc', Description)")
)
Query Options
new ODataQueryBuilder("SERVICE/Entity")
.Filter(f => f.Eq("Status", "Active"))
.Select("Field1", "Field2") // $select=Field1,Field2
.Expand("to_Item", "to_Partner") // $expand=to_Item,to_Partner
.OrderBy("CreationDate", descending: true) // $orderby=CreationDate desc
.OrderBy("SalesOrder") // appends: ,SalesOrder asc
.InlineCount() // $inlinecount=allpages
.Skip(0) // $skip=0
.Top(100) // $top=100
.SkipToken(nextPage) // $skiptoken=200 (null = no-op)
.Build();
⚠️ SAP OData v2 —
$orderbyis required when using$skipor$topPer the OData v2 specification:
If the data service URI contains a
$skipquery option but does not contain an$orderbyoption, then the entities in the set MUST first be fully ordered by the data service. Such a full order SHOULD be obtained by sorting the entities based on their EntityKey values.This means that when you use
.Skip()or.Top()without an explicit.OrderBy(), SAP S/4 will silently fall back to ordering by the entity's primary key — regardless of any sorting you may expect. This produces unpredictable and inconsistent paging results across requests.Always pair
.Skip()/.Top()with an explicit.OrderBy():// ✗ Avoid — SAP will order by primary key, results may be inconsistent across pages new ODataQueryBuilder("API_SALES_ORDER_SRV/A_SalesOrder") .Filter(f => f.Eq("SoldToParty", "1000")) .Skip(100) .Top(100) .Build(); // ✓ Correct — explicit order guarantees consistent paging new ODataQueryBuilder("API_SALES_ORDER_SRV/A_SalesOrder") .Filter(f => f.Eq("SoldToParty", "1000")) .OrderBy("SalesOrder") .Skip(100) .Top(100) .Build();Note:
.SkipToken()uses SAP server-driven pagination and does not have this constraint — the server manages the cursor internally.
Mixed AND / OR Filters
Top-level filter clauses are joined with and. Use .In() or .Or() to introduce or groups — they are automatically wrapped in parentheses so operator precedence is correct.
SAP note: SAP OData V2 does not support the
inoperator. The.In()method is a convenience that expands toorexpressions, which SAP fully supports.
// Combine equality checks with an IN-style OR group
var url = new ODataQueryBuilder("API_BUSINESS_PARTNER/A_BusinessPartner")
.Filter(f => f
.Eq("SalesOrganization", salesOrg)
.In("BusinessPartnerGrouping", "YUTR", "Y012", "YPAY", "YICP", "YSHP")
.Eq("DistributionChannel", "10"))
.Build();
// $filter=SalesOrganization eq '1000'
// and (BusinessPartnerGrouping eq 'YUTR' or BusinessPartnerGrouping eq 'Y012' or BusinessPartnerGrouping eq 'YPAY' or BusinessPartnerGrouping eq 'YICP' or BusinessPartnerGrouping eq 'YSHP')
// and DistributionChannel eq '10'
You can also use .Or() for the same effect when you prefer the explicit builder:
.Filter(f => f
.Eq("Status", "Active")
.Or(o => o
.Eq("Type", "A")
.Eq("Type", "B")))
// $filter=Status eq 'Active' and (Type eq 'A' or Type eq 'B')
Nested Filters
.Or() and .Group() accept a nested builder, so you can combine them for deeper nesting:
var url = new ODataQueryBuilder("API_SALES_ORDER_SRV/A_SalesOrder")
.Filter(f => f
.Eq("SalesOrganization", "1000")
.Or(o => o
.Group(g => g.Eq("Region", "US").Eq("Type", "A"))
.Group(g => g.Eq("Region", "EU").Eq("Type", "B"))))
.Build();
// $filter=SalesOrganization eq '1000'
// and ((Region eq 'US' and Type eq 'A') or (Region eq 'EU' and Type eq 'B'))
OData Batch Query
The Sap Client supports the ability to execute simple batch queries against a SAP OData endpoint.
To execute a batch query against an OData endpoint the ExecuteBatchQueryAsync method can be used to submit and retrieve the result of the batch query.
BatchQueryResponse batchQueryResponse = await sapClient.ExecuteBatchQueryAsync(
"API_PRODUCT_SRV/$batch?sap-client=200",
new List<BatchQueryRequest>
{
new BatchQueryRequest("A_Product(Product='131187')"),
new BatchQueryRequest("A_Product(Product='386110')")
});
Handling Data-Level Errors (ISapErrorAware)
NOTE It is a Uniphar requirement, that all custom S4 services return
ErrorOccuredandErrorTextwhen an unexpected issue occurs. In theory, all custom S4 APIs should expose these functionalities.
Some SAP OData entities contain inline error indicators (e.g. an ErrorOccured field set to "X") rather than returning an HTTP error status (surprise, they always return 200 when an error may occur).
Setup in consuming projects
SAP OData proxy classes are generated as partial classes. To opt in to error detection, implement the library interfaces via partial class declarations:
1. Mark child entity types that carry error fields as ISapErrorAware:
using Uniphar.Sap.OData.Client.V2;
namespace Uniphar.Inventory.Api.Sap.Odata;
public partial class Zsd_cds_material_avail_detType : ISapErrorAware;
public partial class Zsd_cds_material_po_outType : ISapErrorAware;
2. Mark parent entity types that contain navigation properties with ISapErrorAwareContainer:
using Uniphar.Sap.OData.Client.V2;
namespace Uniphar.Inventory.Api.Sap.Odata;
public partial class Zsd_cds_material_availabilityType : ISapErrorAwareContainer
{
public IEnumerable<ISapErrorAware> GetErrorAwareChildren()
{
foreach (var item in to_ATPStock ?? [])
yield return item;
foreach (var item in to_PurchaseOrders ?? [])
yield return item;
}
}
This allows the library to automatically detect errors in nested navigation properties without reflection.
Automatic detection via SapDataError
When ParseResponseAsync deserialises a successful OData response, it automatically scans the data for ISapErrorAware errors. The result is exposed on SapResponse<T>:
| Property | Type | Description |
|---|---|---|
SapDataError |
SapDataError? |
The first data-level error found, or null |
Detection covers:
- Single entity implementing
ISapErrorAwaredirectly - Collection (
NavigationList<T>) — each item is checked, then its children - Nested navigation properties — via
ISapErrorAwareContainer.GetErrorAwareChildren()
Usage in your API / service layer:
var response = await sapClient.GetAsync<NavigationList<Zsd_cds_material_availabilityType>>(path);
// 1. HTTP-level failure
if (response.Failed)
{
return Result.Fail<TResponse>(new HttpResultError(
Title: "SAP Request Failed",
Description: response.ErrorMessage,
StatusCode: response.StatusCode));
}
// 2. Data-level error (SAP returned 200 but an entity has ErrorOccured = "X")
if (response.SapDataError is not null)
{
logger.LogWarning("SAP data error: {ErrorText}", response.SapDataError.ErrorText);
return Result.Fail<TResponse>(new HttpResultError(
Title: "SAP Data Error",
Description: response.SapDataError.ErrorText,
StatusCode: (int)HttpStatusCode.UnprocessableEntity));
}
// 3. Success
return Result.Ok(response.Data.Select(d => d.MapToDto()));
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Azure.Extensions.AspNetCore.Configuration.Secrets (>= 1.4.0)
- Microsoft.Extensions.Http.Resilience (>= 10.1.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated | |
|---|---|---|---|
| 9.4.0 | 250 | 4/24/2026 | |
| 9.3.5 | 423 | 4/2/2026 | |
| 9.3.4 | 475 | 3/13/2026 | |
| 9.3.3 | 145 | 3/10/2026 | |
| 9.3.2 | 120 | 3/10/2026 | |
| 9.3.1 | 104 | 3/9/2026 | |
| 9.3.0 | 118 | 3/6/2026 | |
| 9.2.5 | 389 | 2/11/2026 | |
| 9.2.4 | 102 | 2/10/2026 | |
| 9.2.3 | 319 | 1/15/2026 | |
| 9.2.2 | 116 | 1/15/2026 | |
| 9.2.1 | 136 | 1/13/2026 | |
| 9.1.1 | 134 | 1/13/2026 | |
| 9.1.0 | 218 | 1/5/2026 | |
| 9.0.0 | 137 | 12/30/2025 | |
| 8.4.4 | 541 | 12/30/2025 | |
| 8.4.3 | 151 | 12/29/2025 | |
| 8.4.2 | 135 | 12/29/2025 | |
| 8.4.1 | 412 | 12/19/2025 | |
| 8.4.0 | 320 | 12/18/2025 |