Use the OData v2 Type-safe Client API
Prerequisites
To leverage the type-safe OData v2 client, you need to add following dependencies to the dependencies
section of your pom.xml
:
<dependency>
<groupId>com.sap.cloud.sdk.datamodel</groupId>
<artifactId>odata-core</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>javax.inject</groupId>
<artifactId>javax.inject</artifactId>
<scope>provided</scope>
</dependency>
Furthermore, you need to have generated a client library for your OData service. See the generator documentation for how to generate such a client library.
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.
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:
HttpDestination 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
orreadEntities
.withParameter
corresponds to:- OData query parameters e.g.
filter
ororderby
- Or other modifiers like custom headers
- OData query parameters e.g.
- 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.
The service is represented by the BusinessPartnerService
class which you can obtain by generating a client for the Business Partner service.
The following code snippets assume that such a client is generated 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()
orreplacingEntity()
which will result inHTTP PATCH
orHTTP PUT
respectively. By default, entities are modified viaPATCH
.
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.
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);
Handling of Cross-Site Request Forgery Tokens
For create, update and delete requests the SAP Cloud SDK will try to send a cross-site request forgery (CSRF) token. Upon execution, the request will try to fetch a token first before issuing the 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 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.
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();
}
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.
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 connection pool. If you do not evaluate all responses in a BatchResponse
consider using the try-with-resources
construct while executing batch requests.
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.
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.
- Modify Entity (default)
- Replace Entity
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);
The replacing entity update strategy attempts to replace the full entity in the remote system. It issues a PUT request and submits all fields in the update request, regardless of any field changes.
This update strategy can be explicitly chosen to invoke the method replacingEntity()
while building the update request.
service.updateBusinessPartner(partner)
.replacingEntity()
.executeRequest(destination);
For excluding read-only fields or dependent properties that are not tied to key properties, from your PUT
request payload, use excludingFields()
on your update request.
It also allows you to exclude navigation properties from your request.
service.updateBusinessPartner(partner)
.replacingEntity()
.excludingFields(BusinessPartner.BUSINESS_PARTNER_FULL_NAME,BusinessPartner.TO_BUSINESS_PARTNER_ADDRESS)
.executeRequest(destination);
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();
}
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 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 );
Please note that the OData service is not obliged to respect this setting.