Skip to main content

Use the OData v2 Type-safe Client API

Prerequisites

To leverage the type-safe OData v2 client you need to have generated a client library for your OData service. See the generator documentation for how to generate such a client library.

Discover services on the SAP Business Accelerator Hub

If you would like to consume a specific service, try searching for it on the SAP Business Accelerator Hub and visit the SAP Cloud SDK tab on the landing page of that service. There you will find detailed instructions on how to use the SAP Cloud SDK to get a client library for that specific service. Learn more about the integration with the SAP Business Accelerator Hub in the blog.

Build and Execute OData Requests With the Typed OData Client

The typed OData v2 client allows building type-safe OData v2 requests for a given service. The Java classes represent the data model and the available operations of the service. As a consequence all requests that are built through the typed OData v2 client are not only syntactically valid but also semantically valid.

Generic OData Client

The type-safe client may in some situations not be suitable for your needs. For instance a service might not fully conform to the OData standard, forcing you to perform customized requests. Or the typed API doesn't support a specific OData feature just yet.

For such cases we recommend leveraging our low-level generic OData client. Or to fallback directly to the HTTP client level if that's not enough.

In all the other situations the type-safe client is preferable and considered best practice.

Using the Fluent API

The typed OData client consists of service and data model classes. The service classes mirror the API provided by the OData service and serve as entry point for creating requests. They provide a builder that allows for fluently adding further parameters.

To execute HTTP requests the OData client leverages Destinations and are documented in more detail here. The following code snippets assume that such a destination is in place:

Destination destination;

On an abstract level requests are generally build up according to the following pattern:

result = service.operation()
.withParameter(...)
.executeRequest(destination);
  • operation corresponds to the service's capabilities for entities e.g. createEntity or readEntities.
  • withParameter corresponds to:
    • OData query parameters e.g. filter or orderby
    • Or other modifiers like custom headers
  • Which OData parameters are available depends on the operation. For example, when updating entities the $filter parameter is not available.

Below different OData features are documented using the Business Partner Service on SAP S/4HANA as an example. It is represented by the BusinessPartnerService class which is created by the OData generator when generating a client for this service. The following code snippets assume that a client has been generated and an instance of this service is set up:

BusinessPartnerService service = new DefaultBusinessPartnerService();

OData Features

Basic CRUD Operations

Create, Read, Update and Delete operations on entities are build from the associated service class:

service.createBusinessPartner(partner);
service.getBusinessPartnerByKey("id");
service.getAllBusinessPartner();
service.updateBusinessPartner(partner);
service.deleteBusinessPartnerAddress(address);

Each of the above statements returns a builder object that allows for specifying certain request parameters, depending on the operation.

The following query parameters and request options are available for these operations:

Query parameters:

  • $select and $expand are available on reading a single or multiple entities
  • $filter, $top, $skip and $orderby are available only when reading a collection of entities

Request parameters:

  • All operations allow for adding custom headers via withHeader(...)
  • Update operations allow to set either modifyingEntity() or replacingEntity() which will result in HTTP PATCH or HTTP PUT respectively. By default, entities are modified via PATCH.

Handling of ETags

An ETag is a version identifier that is often used to implement an optimistic locking mechanism. The SAP Cloud SDK will try to read version identifiers from responses and set them when sending OData requests that manipulate data.

Consider the following example:

partner = service.getBusinessPartnerByKey("id")
.executeRequest(destination);
response = service.updateBusinessPartner(partner)
.executeRequest(destination);
// update the partner reference
partner = response.getModifiedEntity();

On the read request, the SAP Cloud SDK will automatically try to extract the version identifier from the response and store it within the partner object. When updating it will be taken from there and sent with the If-match header.

note

If a service requires this header to be sent: Fetching the entity from the service first is essential to ensure that the ETag is present and up to date.

This behavior can be customized. The following code sends a * in the IF-MATCH header which essentially corresponds to a forced overwrite.

service.updateBusinessPartner(partner)
.matchAnyVersionIdentifier()
.executeRequest(destination);

Select

When reading entities, the API offers select( ... ) on the builders. Through it, the query parameters $select and $expand are set. It takes in the properties of the entity being queried. Primitive properties are added to $select while complex and navigational properties are added to $expand. This handling is done automatically by the SAP Cloud SDK.

The properties that can be selected or expanded are represented via static fields on the entity class. There will be a field for each property. E.g. for the business partner entity one can find BusinessPartner.FIRST_NAME and BusinessPartner.LAST_NAME.

service.getBusinessPartnerByKey("id")
.select(BusinessPartner.FIRST_NAME, BusinessPartner.LAST_NAME, BusinessPartner.TO_BUSINESS_PARTNER_ADDRESS)
.executeRequest(destination);

The above translates to the following query parameters:

$select=FirstName,LastName,to_BusinessPartnerAddress/*&$expand=to_BusinessPartnerAddress

One can also apply select again to the expanded object:

service.getBusinessPartnerByKey("id")
.select(BusinessPartner.FIRST_NAME,
BusinessPartner.TO_BUSINESS_PARTNER_ADDRESS
.select(BusinessPartnerAddress.ADDRESS_ID,
BusinessPartnerAddress.CITY_CODE))
.executeRequest(destination);

The above translates to the following query parameters:

$select=FirstName,to_BusinessPartnerAddress/AddressID,to_BusinessPartnerAddress/CityCode&$expand=to_BusinessPartnerAddress

Filter

When operating on a collection of entities, the API offers filter( ... ) on the builders. It directly corresponds to the $filter parameter of the request. Filters are also built via the static property fields on entities.

The following example:

/*
Get all business partners that either:
- Have first name 'Alice' but not last name 'Bob'
- Or have first name 'Mallory'
*/
service.getAllBusinessPartner()
.filter(BusinessPartner.FIRST_NAME.eq("Alice")
.and(BusinessPartner.LAST_NAME.ne("Bob"))
.or(BusinessPartner.FIRST_NAME.eq("Mallory"))
)
.executeRequest(destination);

Will translate to this filter parameter:

$filter=(((FirstName eq 'Alice') and (LastName ne 'Bob')) or (FirstName eq 'Mallory'))

Take note of the order of and and or. As or is invoked on the result of and it will form the outer expression while and is an inner expression in the first branch of or.

To achieve a different order with and as the top level statement one would nest the or within and(...):

.and(BusinessPartner.LAST_NAME.ne("Bob")
.or(BusinessPartner.FIRST_NAME.eq("Mallory"))
)
.and(BusinessPartner.LAST_NAME.ne("Bob")
.or(BusinessPartner.FIRST_NAME.eq("Mallory"))
)

Available Filter Expressions

The OData v2 standard allows for a limited range of filter expressions compared to OData v4. A detailed list of what is available in the SAP Cloud SDK can be obtained from the Javadoc. The functionality can also be discovered through the fluent API.

Custom Filter Expressions

The SAP Cloud SDK for Java also allows for providing custom filter expressions. This is useful for instance when the fields to filter by are not known at design time and have to be passed dynamically.

For example, to filter based on a field ShoeSize use:

ValueBoolean filterExpression = FieldReference.of("ShoeSize").equalTo(42);
ExpressionFluentHelper<MyEntity> customFilter = new ExpressionFluentHelper<>(filterExpression);

// use the custom filter in the .filter() method of your request builder
myService.getAllBusinessPartner().filter(customFilter).executeRequest(destination);

Which produces the HTTP query:

?$filter=(ShoeSize eq 42)

Accessing Extensible or Custom Fields

The concept of In-app Extensibility allows users to introduce additional fields to an existing SAP S/4HANA service. To consume and work with such custom fields in your application code, SAP Cloud SDK introduces the concept of custom fields.

You can set custom fields on an entity through the .setCustomField method and read the value in the entity using .getCustomField(fieldName)

businessPartner.setCustomField("shoeSize", 10);

final int shoeSize = businessPartner.getCustomField("shoeSize");

If you are unsure about the custom fields added by the customer, you can still use getCustomFieldNames() to obtain the set of custom fields introduced. Use this set further on to pull values from the entity.

For e.g.

final Set<String> customfieldNames = businessPartner.getCustomFieldNames();

for(String customFieldName: customfieldNames){
final Object customField = businessPartner.getCustomField(customFieldName);
}

The accessors for custom fields respect multitenancy. getCustomFieldNames(), for example, fetches only custom field names available in the context of the current tenant.

Using Custom Fields in System Query Options

You can also pass custom fields to select or filter system query options:

service().getAllBusinessPartner()
.select(new BusinessPartnerField<>("ShoeSize"))
.select(()->"AnotherCustomField")
.filter(new BusinessPartnerField<>("Shoesize").eq(42))
.executeRequest(destination)

Please be aware that such an example can work for all tenants only if ShoeSize and AnotherCustomField are custom fields that have been defined by all tenants.

Batch Requests

Batch requests allow wrapping multiple OData requests into one single batch call. Thereby we reduce the number of round trips to the remote server. Refer to the official OData v2 spec for further reference about batch requests, their semantics, and the request/response format.

Execute Batch Request

The service object offers the method batch which allows access to the batch-related methods that help build the batch request.

Multiple single requests like createBusinessPartnerAddress and others can be wrapped into an OData BATCH ChangeSet. OData BATCH changesets are meant to wrap transactional requests together for better performance. OData Function Import can be also used inside OData Batch changesets, for that use the method addFunctionImport(...). Each changeset is opened with beginChangeSet and closed with endChangeSet. You can wrap multiple changesets into one batch request.

Non-modification or in other words Read requests can be added outside changesets with the addReadOperations method.

note

Please be aware that function imports using GET cannot be included inside a Batch changeset using addFunctionImport(...) Add them into batch requests outside changesets using addReadOperations method.

Use executeRequest to issue the batch request to the remote system. We receive an instance of BatchResponse as the result object.

BusinessPartnerAddress addressToCreate1;
BusinessPartnerAddress addressToCreate2;

BusinessPartnerFluentHelper requestTenEntities = service.getAllBusinessPartner().top(10);
BusinessPartnerByKeyFluentHelper requestSingleEntity = service.getBusinessPartnerByKey("bupa9000");
BusinessPartnerFictionalFluentHelper callFictionalFunction = service.fictionalFunction(); //Assuming this fictional function import uses GET

BatchResponse result =
service
.batch()
.beginChangeSet()
.createBusinessPartnerAddress(addressToCreate1)
.createBusinessPartnerAddress(addressToCreate2)
.endChangeSet()
.addReadOperations(requestTenEntities)
.addReadOperations(requestSingleEntity)
.addReadOperations(fictionalFunction)
.executeRequest(destination);

Alternatively, you can also assemble changesets from individual requests with addChangeSet. This option allows you to further configure your requests by, for example, attaching custom headers to special parts of your changeset like so:

BusinessPartnerAddress addressToCreate;
BusinessPartnerAddress addressToUpdate;
BusinessPartnerAddress addressToDelete;

BusinessPartnerAddressCreateFluentHelper createRequest =
service
.createBusinessPartnerAddress(addressToCreate)
.withHeader("header-for-create", "create value");
BusinessPartnerAddressUpdateFluentHelper updateRequest =
service
.updateBusinessPartnerAddress(addressToUpdate)
.withHeader("header-for-update", "update value");
BusinessPartnerAddressDeleteFluentHelper deleteRequest =
service
.deleteBusinessPartnerAddress(addressToDelete)
.withHeader("header-for-delete", "delete value");

BatchResponse result =
service
.batch()
.addChangeSet(createRequest, updateRequest, deleteRequest)
.executeRequest(destination);

Access Batch Response

In OData Batch, the response is structured similarly to the request. The unordered responses of data modification requests are accessible with a given changeset index. The ordered responses of data retrieval requests are accessible with the reference of the original request object.

Changeset Response

Use the method get to access the individual changeset responses by index.

Try<BatchResponseChangeSet> changeSetTry = result.get(0);

The returned Try allows checking if the respective changeset was processed successfully by the remote server. Per the specification, either all operations or none within the same changeset are successful. Hence, if changeSetTry.isSuccess() is true, you can derive that all operations in the changeset are successful.

In case the changeset contained at least one create operation, you can gain access to the created entity representations by using the method getCreatedEntities.

if (changeSetTry.isSuccess()) {
//now you can access the created entities
final List<VdmEntity<?>> createdEntities = changeSet.get().getCreatedEntities();
}
note

You have to cast the entity representations from the generic type VdmEntity to the respective subclasses.

Read Response

Use the method getReadResult to extract the deserialized response of a given read operation:

List<BusinessPartner> entities = result.getReadResult(requestTenEntities);
BusinessPartner entity = result.getReadResult(requestSingleEntity);
List<functionResult> functionResult = result.getReadResult(callFictionalFunction);

You can use the same methods to extract deserialized responses of function imports that use GET.

Instead of an index to indicate the batch position, the API expects the original request reference. The request-response mapping and deserialization are happening internally.

note

If all responses in a BatchResponse are evaluated using getReadResult(...) or/and get(index), this ensures that the underlying InputStream of the HttpEntity is fully consumed and the HTTP connection is released back to the pool. If you do not evaluate all responses in a BatchResponse consider using the try-with-resources construct while executing batch requests.

tip

To ensure that all resources are properly closed while executing batch requests, we recommend using the try-with-resources construct.

  try(
BatchResponse result = service.batch()
.addReadOperations(readOperation)
.addChangeSet(createRequest, updateRequest, deleteRequest)
.executeRequest(destination)
) {
List<BusinessPartner> entities = result.getReadResult(readOperation);
Try<BatchResponseChangeSet> changeSetTry = result.get(0);
}

This is especially helpful in preventing connection leaks if you are re-using a HttpClient to execute multiple batch requests.

Entity Update Strategies

The SAP Cloud SDK supports different strategies for updating entities which differ in the HTTP method and the payload of the update request.

info

To update an entity it should first be retrieved from the service. This ensures the latest state of the entity is updated. Otherwise, updating with old data could erase changes that have been made to the entity in the meantime. Many services will enforce this behavior with optimistic locking mechanisms. Refer to the section about ETags for details.

The default strategy is the modifying entity update strategy which attempts to modify only the necessary entity fields in the remote system. It issues a PATCH request and includes only the fields in the request payload whose values were changed by its setter method. Hence, fields whose values remain unchanged are not sent to the target system. Calling the method includingFields(fields ...) instructs the SAP Cloud SDK to add the mentioned fields explicitly in the update request. You could also add navigation properties to your request using the method. This is useful for backend systems that require some unchanged fields in the request payload for given reasons.

This update strategy can be explicitly chosen by invoking the method modifyingEntity() while building the update request.

service.updateBusinessPartner(partner)
//method call optional, since active by default
.modifyingEntity()
//add the Business Partner full name and address explicitly to the update request
.includingFields(BusinessPartner.BUSINESS_PARTNER_FULL_NAME,BusinessPartner.TO_BUSINESS_PARTNER_ADDRESS)
.executeRequest(destination);
note

It depends on the capabilities of the specific OData service in the remote system which update strategies are supported in an end-to-end scenario. For example, there are cases where OData services do not support PUT requests.

Error Handling

Sometimes requests fail and the SAP Cloud SDK provides a flexible way to deal with such failures on multiple levels. All executeRequest methods may throw a runtime exception (extending) ODataException. This will always contain the request which was (attempted to be) sent out as well as the cause of the exception. To handle all kind of failures consider the following code:

try { ... }
catch( final ODataException e ) {
ODataQueryGeneric query = e.getQuery();
logger.debug("The following query failed: {}", query);
// do something else
}

This handling is the most generic, handling all possible failures. For more specific information dedicated exceptions are inheriting from ODataException. Please check the documentation for details on all the exception types.

To handle different kinds of failure one can list multiple catch clauses to cover different levels or cases that might occur, e.g.:

try { ... }
catch( ODataServiceErrorException e ) {
// handle the specific error message from the response payload
ODataServiceError odataError = e.getODataError();
logger.debug("The OData service responded with an error: {}", odataError);
} catch( ODataDeserializationException e ) {
// handle failures in deserialization
} catch( ODataResponseException e ) {
// handle all other errors originating from handling the HTTP response
int httpCode = e.getHttpCode();
Collection<Header> httpCode = e.getHttpHeaders();
Option<String> httpBody = e.getHttpBody();
}

A navigation property describes a unidirectional relationship between two entity types. Like other properties, it has a name and declares a multiplicity, i.e. whether to expect a single or multiple values. Additionally, a navigation property allows for dedicated CRUD operations, that may not be exposed by default on entity sets of the service root. Such operations also provide a convenient way to access the nested resources of entities.

The typed OData client for OData v2 supports the following operations on (first-level only) navigation properties:

  • Create

The below example leverages the creation of a nested entity in relation to an existing entity:

/*
Create a new address for a specific business partner.
*/
BusinessPartner businessPartnerById = BusinessPartner.builder().businessPartner("123").build();
BusinessPartnerAddress addressItem = BusinessPartnerAddress.builder().country("DE").build();

service.createBusinessPartnerAddress( addressItem )
.asChildOf( businessPartnerById, BusinessPartner.TO_BUSINESS_PARTNER_ADDRESS )
.executeRequest( destination );

This sample API call translates to the following service request:

POST /ODataService/API_BUSINESS_PARTNER/A_BusinessPartner(123)/to_BusinessPartnerAddress
{
"country": "de"
}

Server-Driven Paging

Pagination describes the practice of splitting a collection of entities from reading requests into one or many pages. The paging behavior is determined by both the server and the client.

OData service operators may decide to enable server-driven pagination to limit the amount of data that is fetched and sent over the network to the client. Generic query options can yield huge amounts of data. By splitting the result-set into sequential pages of entities, the data can be requested incrementally. This reduces initial network load and improves overall response times. If the payload of an OData response contains a link including a $skiptoken, then it indicates a next page to the result-set. The iterable result-set is a consistent snapshot of the data, it can not change between reading individual pages.

In comparison, the OData consumer may use query options $top and $skip (client-driven paging) to read partial data from the result-set. But relying on a consistent state while browsing through the data can be problematic. Between individual requests, another user could delete or add an item. This would result in an inconsistent aggregation of data.

By default, the SAP Cloud SDK automatically resolves all pages of a result-set if server-driven paging is encountered. For the API consumer, it is not necessary to parse the next link and instantiate follow-up requests to aggregate the full result-set.

List<BusinessPartner> iterablePagesOfEntities = service
.getAllBusinessPartner()
.executeRequest( destination );

In case memory efficiency and response time of the consuming application becomes a priority, then the advanced API provides additional means to manually iterate through the internal pages. While accessing the following methods, the internal HTTP requests are executed lazily:

Iterable<List<BusinessPartner>> iterablePagesOfEntities = service
.getAllBusinessPartner()
.iteratingPages()
.executeRequest( destination );

Iterable<BusinessPartner> iterableEntities = service
.getAllBusinessPartner()
.iteratingEntities()
.executeRequest(destination);

Stream<BusinessPartner> streamingEntities = service
.getAllBusinessPartner()
.streamingEntities()
.executeRequest(destination);

The request builder allows for setting the optional parameter for preferred page size, e.g.:

Iterable<List<BusinessPartner>> iterablePagesOfEntities = service
.getAllBusinessPartner()
.withPreferredPageSize(20)
.iteratingPages()
.executeRequest( destination );
caution

Please note that the OData service is not obliged to respect this setting.

Cross Site Request Forgery (CSRF)

Many OData server (especially SAP S/4HANA services) require proper CSRF-token handling when creating, updating, or deleting entities for security reasons. The SAP Cloud SDK for Java supports this requirement by sending a pre-flight request to obtain a valid CSRF token for the subsequent, actual request.

By default, this pre-flight request is sent automatically and every time before running a modification request. In single-threaded workloads, this approach works without causing issues. In multi-threaded scenarios, however, this behavior can lead to situations where the OData service rejects the request with a CSRF token validation failed response.

This is related to how HTTP Client Caching works in the SAP Cloud SDK. Assume we are sending multiple OData requests in parallel:

for (var i = 0; i < 10; ++i) {
ThreadContextExecutors.submit(() -> {
return service.createBusinessPartner(partner).executeRequest(destination);
});
}

In this example, all 10 requests will be executed in parallel. While doing so, the SAP Cloud SDK eventually needs an HTTP client to actually run the HTTP calls. This client is retrieved via the HttpClientAccessor and, therefore, will be the exact same instance across all 10 threads.

In itself, this is not an issue and, in fact, is the desired behavior because it performs a lot better than creating a new HTTP client instance for every call. What is a problem, however, is that the OData service, at least in the SAP S/4HANA case, sends back the CSRF token twice: Once in a special header (the x-csrf-token header) and once in a Set-Cookie header. The HTTP cookie ends up being stored within the HTTP client instance so that it can be used to carry some server-set state across multiple requests.

In our multi-threaded example, this is an issue because there is a race condition between the 10 threads. Since the SAP Cloud SDK executes two requests (the pre-flight and the actual request) for every modification request, it is possible that the pre-flight request of one thread overwrites the CSRF cookie of another thread. This can lead to an inconsistent state where the Cookie sent by the SAP Cloud SDK does no longer match the x-csrf-token header sent by the OData service. The OData service recognizes this inconsistency and rejects the request.

Solution

To avoid issues with CSRF tokens in multi-threaded scenarios, we suggest to manually fetch the CSRF token and then use it like so:

HttpClient httpClient = HttpClientAccessor.getHttpClient(destination);
CsrfToken csrfToken =
new DefaultCsrfTokenRetriever().retrieveCsrfToken(httpClient, BusinessPartnerService.DEFAULT_SERVICE_PATH);

for( var i = 0; i < 10; ++i ) {
ThreadContextExecutors.submit(() -> {
BusinessPartner newBusinessPartner;
ODataRequestResultGeneric untypedResult =
service
.createBusinessPartner(newBusinessPartner)
.withHeader(DefaultCsrfTokenRetriever.X_CSRF_TOKEN_HEADER_KEY, csrfToken.getToken())
.toRequest()
.execute(httpClient);

return ModificationResponse.of(untypedResult, newBusinessPartner, destination);
});
}

The code above solves the issue explained above by doing the following:

  1. We are pre-fetching the CSRF token once and then re-using it across all threads. That way, the SAP Cloud SDK won't run a pre-flight request and the CSRF token is going to be consistent.
  2. We are fetching the HTTP client just once and then re-use it across all threads. This avoids an edge-case issue where the HTTP client cache expires while we are still running requests.