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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Uniphar.Sap.OData.Client" Version="9.4.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Uniphar.Sap.OData.Client" Version="9.4.0" />
                    
Directory.Packages.props
<PackageReference Include="Uniphar.Sap.OData.Client" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Uniphar.Sap.OData.Client --version 9.4.0
                    
#r "nuget: Uniphar.Sap.OData.Client, 9.4.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Uniphar.Sap.OData.Client@9.4.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Uniphar.Sap.OData.Client&version=9.4.0
                    
Install as a Cake Addin
#tool nuget:?package=Uniphar.Sap.OData.Client&version=9.4.0
                    
Install as a Cake Tool

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: : FetchTokenStatusCodeTriggers is a list of HttpCodes we test against to trigger fetching a new token. We predefine the status code to check for as Forbidden (403). If the issue we encountered before (returning 401 instead of 403) 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 UseHttpClientHandler and PooledConnectionLifetime have been retired with version 2.0.2, as we now use short-living http client instances created by HttpClientFactory. These are no longer part of SapClientOptions either.

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 Success
  • int StatusCode
  • string? Error
  • T 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

  1. Use SapClientKeyVaultSecretManager to map specific credentials to the base configuration

    builder.Configuration.AddAzureKeyVault(
      new Uri(kvUri), tokenCredential,
      new AzureKeyVaultConfigurationOptions
      {
        ReloadInterval = TimeSpan.FromMinutes(30),
        Manager = new SapClientKeyVaultSecretManager("S4") // or "EWM"
      });
    
  2. Bind SapClientOptions so it can used in Options pattern:

    builder.Services.Configure<SapClientOptions>(
        builder.Configuration.GetSection(nameof(SapClientOptions)));
    
  3. Register a singleton instance of HttpClient, as SapClient makes 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.

  4. 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 — $orderby is required when using $skip or $top

Per the OData v2 specification:

If the data service URI contains a $skip query option but does not contain an $orderby option, 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 in operator. The .In() method is a convenience that expands to or expressions, 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 ErrorOccured and ErrorText when 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 ISapErrorAware directly
  • 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.