Typed client to consume OData v4 Services for Java

Build and execute OData Requests with the typed OData client#

The typed OData v4 client allows to build type-safe OData v4 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 build through the typed OData client are not only syntactically valid but also semantically valid.

Using the Fluent API#

The typed OData v4 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 which allows for adding further parameters in a fluent way.

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:

HttpDestination destination;

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

result = service.operation()
.withParameter(...)
.execute(destination);
  • operation corresponds to the service's capabilities for entities e.g. createEntity or readEntities.
  • withParameter corresponds to:
    • OData v4 query parameters e.g. filter or orderby
    • Or other modifiers like custom headers
  • Which OData v4 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 S/4HANA as an example. It is represented by the BusinessPartnerService class which is part of the pre-generated type-safe client for S/4HANA also known as Virtual Data Model (VDM). The following code snippets assume that 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, $expand and $search 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:

  • 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.
  • Update and delete operations allow to modify how ETags are handled:
    • By default an ETag is send if one is present on the entity being modified.
    • matchAnyVersionIdentifier() will instead always send a * which acts as a wildcard to match all ETags.
    • ignoreAnyVersionIdentifier() will ensure that no ETag is sent.
  • All operations allow for adding custom headers via withHeader(...)

Handling of ETags#

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

Consider the following example:

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

On the read request the 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.

Handling of CSRF tokens#

For create, update and delete requests the SDK will try to send a CSRF token. Upon execution the request will try to fetch a token first before issuing the actual create request. Many services require this behavior for security reasons. However, the create request will be made without a CSRF token if none could be obtained.

Select#

When reading entities the API offers select( ... ) on the builders. Through it the query parameters $select and $expand are set. It takes in 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 SDK.

The properties that can be selected or expanded are represented via static fields on the entity class. So 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)
.execute(destination);

The above translates to the following query parameters:

$select=FirstName,LastName&$expand=to_BusinessPartnerAddress

OData v4 allows for formulating nested, fully featured queries on complex and navigational properties. Querying nested objects is possible within expand. That means the following query is possible:

service.getBusinessPartnerByKey("id")
.select(BusinessPartner.TO_BUSINESS_PARTNER_ADDRESS
.select(BusinessPartnerAddress.CITY_CODE, BusinessPartnerAddress.COUNTRY)
.filter(BusinessPartnerAddress.CITY_CODE.notEqualTo("1234"))
.orderBy(BusinessPartnerAddress.COUNTRY.desc())
)
.execute(destination);

The above translates to the following expand query parameter:

$expand=to_BusinessPartnerAddress($select=CityCode,Country;$filter=CityCode eq '1234';$orderby=Country desc)

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 build 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.equalTo("Alice")
.and(BusinessPartner.LAST_NAME.notEqualTo("Bob"))
.or(BusinessPartner.FIRST_NAME.equalTo("Mallory"))
)
.execute(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.notEqualTo("Bob")
.or(BusinessPartner.FIRST_NAME.equalTo("Mallory"))
)

Available Filter Expressions#

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

The below example leverages OData v4 exclusive features to build a more complex request:

/*
Fetch all business partners where:
- the last name is at least twice as long as the first name
- AND the combined string of first and last name does not contain 'bob'
*/
service.getAllBusinessPartner()
.filter(BusinessPartner.FIRST_NAME.length().multiply(2).lessThan(BusinessPartner.LAST_NAME.length())
.and(BusinessPartner.FIRST_NAME.concat(BusinessPartner.LAST_NAME).contains("bob"))
)
.execute(destination);

Batch Requests#

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

warning

The described API is subject to change

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 createBusinessPartnerRequest) can be wrapped into so-called change sets. A change set can be added by addChangeset and the corresponding create, update or delete operations can be passed as partmenters into it. You can wrap multiple change sets into one batch request. Similarly, retrieve operations can be added to the batch request by using addReadOperations.

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

GetAllRequestBuilder<BusinessPartner> getBusinessPartners = service.getAllBusinessPartner();
GetByKeyRequestBuilder<BusinessPartner> getBusinessPartnerByKey = ...
CreateRequestBuilder<BusinessPartner> createBusinessPartnerRequest1 = ...
CreateRequestBuilder<BusinessPartner> createBusinessPartnerRequest2 = ...
DeleteRequestBuilder<BusinessPartner> deleteBusinessPartnerRequest = ...
BatchResponse result =
service
.batch()
.addReadOperations(getBusinessPartners, getBusinessPartnerByKey)
.addChangeset(createBusinessPartnerRequest1, createBusinessPartnerRequest2, deleteBusinessPartnerRequest)
.execute(destination);

Access Batch Response#

Use getModificationResult to access response for a specific create, update or delete operation.

ModificationResponse<BusinessPartner> createResult = response.getModificationResult(createBusinessPartnerRequest1);
ModificationResponse<BusinessPartner> deleteResult = response.getModificationResult(deleteBusinessPartnerRequest);

Similarly, use the method getReadResult to access response for a specific retrieve request.

List<BusinessPartners> readAllResult = response.getReadResult(getBusinessPartners);
BusinessPartner readByKeyResult = response.getReadResult(getBusinessPartnerByKey);

As per the OData specification, either all operations or none within the same change set are successful. Hence, if the change set was a failure then evaluating the result of any of the contained requests will contain the failure of the change set.

Entity Update Strategies#

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

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 Cloud SDK to add the mentioned fields explicitly in the update request. This is useful for backend systems which require some unchanged fields in the request payload for given reasons.

This update strategy can be explicitly chosen 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 explicitly to the update request
.includingFields(BusinessPartner.BUSINESS_PARTNER_FULL_NAME)
.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 SDK provides a flexible way to deal with such failures on multiple levels. All execute 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 there are dedicated exceptions inheriting from ODataException. Please tend to the documentation for all the exception types.

In order 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
}

Note that instead of applying try/catch one can also make use of tryExecute on the request builders.

Navigation properties#

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 v4 supports the following operations on (arbitrarily nested) navigation properties:

  • Create
  • Read
  • Update
  • Delete
  • Count

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.forEntity( businessPartnerById )
.navigateTo( BusinessPartner.TO_BUSINESS_PARTNER_ADDRESS )
.create( addressItem )
.execute( 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 server and 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, in case generic query options 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()
.execute( 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()
.execute( destination );
Iterable<BusinessPartner> iterableEntities = service
.getAllBusinessPartner()
.iteratingEntities()
.execute(destination);
Stream<BusinessPartner> streamingEntities = service
.getAllBusinessPartner()
.streamingEntities()
.execute(destination);

The request builder allows for setting the optional parameter for preferred page size. Please note that the OData service is not obliged to respect this setting.

Last updated on by Alexander Dümont