Use the OData v4 Type-safe Client API
This guide explains how to use features of the OData v4 protocol supported by the SAP Cloud SDK. We will use People service from OData v4 tutorial and Business Partner Service from the SAP S/4HANA ERP suit.
For more details on how to execute requests using a OData type-safe client by the SAP Cloud SDK, refer to this guide.
Entities
In OData, entity collections are the resource against which you execute requests and CRUD operations. The SAP Cloud SDK operates on entity classes, that represent those resources.
An OData JSON representation of a single business partner could be something like this:
{
"FirstName": "Peter",
"LastName": "Pan",
"to_BusinessPartnerAddress": [
{
"Country": "Neverland"
}
]
}
When using the SAP Cloud SDK this entity would be represented as an instance of the BusinessPartner
entity class from the according business partner service.
The properties of entity class instances are held in camel case as is common in JavaScript:
BusinessPartner {
firstName: 'Peter',
lastName: 'Pan',
toBusinessPartnerAddress: [ BusinessPartnerAddress { country: 'Neverland' } ]
}
OData responses, that contain entities are automatically deserialized to the respective entity class when using the SAP Cloud SDK. To execute create or update requests you have to build an instance of an entity class, that represents the data to be created or updated. There are three ways to build entities on your own as described below.
Custom Fields
In the real world, OData service implementations can differ from their original service specifications. This can happen due to incorrect specifications or customizations of the service. The SAP Cloud SDK supports custom fields on your entities, that are not covered by the specification the according service is based on.
You can set custom fields on an entity through the .setCustomFields
and .setCustomField
methods.
Setting custom fields with existing keys overrides the existing fields.
Non-existent fields are added without removing other fields.
// add custom fields to the existing fields
businessPartner.setCustomFields({
myCustomField: 'this is custom'
});
// add specific custom field
businessPartner.setCustomField('myCustomField', 'this is custom');
You can also access existing fields using the .getCustomField
and .getCustomFields
methods.
// get all custom fields
const customFields = businessPartner.getCustomFields(); // { myCustomField: 'this is custom' }
// get specific custom field
const customFields: = businessPartner.getCustomField(); // 'this is custom'
As custom fields are not defined through the service specification, the type of their values is unknown. Therefore, custom fields are never automatically serialized or deserialized. If you are using custom fields, you might have to take care of serialization on your own.
Build an Entity From Scratch
To build an entity by assigning its properties, you can use the entity builders provided by every entity class.
Use the static .builder
method to access the builder, set the properties and finally use the .build
method to yield the entity.
To set navigation properties, that link to other entities, you have to create the linked entities using their respective builder.
Navigation properties, that are linked through a one-to-many relation have to be assigned in an array - one-to-one relations are assigned as single objects.
The example below shows how you can create the data from above using the entity builder.
The relation to the business partner address is a one-to-many relation here.
import {
BusinessPartner,
BusinessPartnerAddress
} from '@sap/cloud-sdk-vdm-business-partner-service';
const businessPartner = BusinessPartner.builder()
.firstName('Peter')
.lastName('Pan')
.toBusinessPartnerAddress([
BusinessPartnerAddress.builder().country('Neverland').build()
])
.build();
You can also add fields, that are unknown according to the specification, if you add them as custom fields.
To achieve this, pass an object the .withCustomFields
method, where the keys denote the names of the custom fields, and the values their respective values.
import { BusinessPartner } from '@sap/cloud-sdk-vdm-business-partner-service';
const businessPartner = BusinessPartner.builder()
.firstName('Peter')
.withCustomFields({
myCustomField: 'this is custom'
})
.build();
Build an Entity From a JSON Representation
Sometimes, it makes sense for you to store your data as a JSON object, that is based on the entity type. For example, when using the property names of the entity class as properties of your object. If you are looking for a way to create an entity from a JSON response, that you got from an OData service, you are probably looking for entity deserialization.
This would be the JSON representation of a business partner in the SAP Cloud SDK:
{
"firstName": "Peter",
"lastName": "Pan",
"toBusinessPartnerAddress": [
{
"country": "Neverland"
}
]
}
You can use this data to build an entity using the .fromJson
method.
The example below shows how you would create an instance of the business partner class using the .fromJson
method.
import { BusinessPartner } from '@sap/cloud-sdk-vdm-business-partner-service';
const businessPartner = BusinessPartner.builder().fromJson({
firstName: 'Peter',
lastName: 'Pan',
toBusinessPartnerAddress: [
{
country: 'Neverland'
}
]
});
If there are unknown fields present in the JSON object, they will be treated as custom fields.
Deserialize an OData JSON Response to an Entity
In some cases you might retrieve raw data from an OData response.
If you need to transform it to an SAP Cloud SDK representation of an entity, you can deserialize it using the deserializeEntity
function.
Fields unknown according to the specification are added as custom fields, without deserializing the according value.
Note that this function is not part of a specific service and has to be imported from the SAP Cloud SDK core package.
import { deserializeEntity } from '@sap-cloud-sdk/core';
const businessPartner = deserializeEntity(
{
FirstName: 'Peter',
LastName: 'Pan',
to_BusinessPartnerAddress: [
{
Country: 'Neverland'
}
]
},
BusinessPartner
);
GetAll Request Builder
The GetAll
request builder allows you to create a request to retrieve all entities that match the request configuration.
BusinessPartner.requestBuilder().getAll();
The example above creates a request to get all BusinessPartner
entities.
Select
When reading entities, the API offers select( ... )
on the builders.
Note that for OData v4 a select
does not automatically expand navigation properties.
See here for details on select and expand.
For non-navigational property the select
behaves as follows:
BusinessPartner.requestBuilder()
.getAll()
.select(BusinessPartner.FIRST_NAME, BusinessPartner.LAST_NAME)
.execute(destination);
The above translates to the following query parameters:
$select=FirstName,LastName
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.
/*
Get all business partners that either:
- Have first name 'Alice' but not last name 'Bob'
- Or have first name 'Mallory'
*/
BusinessPartner.requestBuilder()
.getAll()
.filter(
or(
and(
BusinessPartner.FIRST_NAME.equals('Alice'),
BusinessPartner.LAST_NAME.notEquals('Bob')
),
BusinessPartner.FIRST_NAME.equals('Mallory')
)
)
.execute(destination);
The example above 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
.
In addition, the negation operator not
can also be used for wrapping any filter expressions.
/*
Get all business partners that do not match any of the cases:
- Have first name 'Alice'
- Have last name 'Bob'
*/
.filter(
not(
or(
BusinessPartner.FIRST_NAME.equals('Alice'),
BusinessPartner.LAST_NAME.equals('Bob')
)
)
)
The $filter
parameter will then be generated like below:
$filter=not (FirstName eq 'Alice') or (LastName eq 'Bob'))
It is also possible to pass multiple filters to the same filter function without concatenating them with and
or or
.
They will be concatenated with and
by default.
The two following examples are equal:
.filter(
and(
BusinessPartner.FIRST_NAME.equals('Alice'),
BusinessPartner.LAST_NAME.notEquals('Bob')
)
)
The example above can be shortened to:
.filter(
BusinessPartner.FIRST_NAME.equals('Alice'),
BusinessPartner.LAST_NAME.notEquals('Bob')
)
Filter on One-to-One Navigation Properties
In addition to basic properties, filters can also be applied to one-to-one navigation properties.
The example below shows how to filter on the TO_CUSTOMER
, which is a one-to-one navigation property of the BusinessPartner entity.
Please note, that the CUSTOMER_NAME
and CUSTOMER_ACCOUNT_GROUP
are properties of the entity Customer
, which is the type of the one-to-one navigation property TO_CUSTOMER
.
/*
Get all business partners that match all the following conditions:
- Have customer with the customer name 'John'
- Have customer with the customer account group '0001'
*/
.filter(
BusinessPartner.TO_CUSTOMER.filter(
Customer.CUSTOMER_NAME.equals('John'),
Customer.CUSTOMER_ACCOUNT_GROUP.equals('0001')
)
)
The generated $filter
will be:
$filter=((to_Customer/CustomerName eq 'John' and to_Customer/CustomerAccountGroup eq '0001'))
Filter on One-to-Many Navigation Properties
OData V4 introduces lambda operators e.g., any
/all
so that the root property of the one-to-many navigation properties can be filtered.
Below is an example that demonstrates how to use the lambda operator any
.
/*
Get all people that have at least one friend that matches all the following conditions:
- Has first name 'John'
- Has last name 'Miller'
*/
.filter(
any(
People.FRIENDS.filter(
People.FIRST_NAME.equals('John'),
People.LAST_NAME.equals('Miller')
)
)
)
The generated $filter
parameter of the URL will be:
$filter=(/any(a0:((a0/Friends/FirstName eq 'John' and a0/Friends/LastName eq 'Miller'))))
More Filter Expressions
More advanced filter expressions can be found here.
Expand
Expand and Select
In contrast to the OData v2 implementation, you have to select
and expand
separately.
In other words, selected properties are not expanded automatically as in v2.
The reason for this difference originates in the way select and expand work in OData v4.
In OData v4 you select within the expand-argument $expand=Friends($select=FirstName)
whereas in OData v2 you select via a path $select=Friends/FirstName&$expand=Friends
.
That's why we mimic this behavior for select
and expand
operations in our API for OData v4 type-safe client.
People.requestBuilder()
.getAll()
.select(People.LAST_NAME)
.expand(People.FRIENDS.select(People.FIRST_NAME, People.ADDRESS_INFO));
In the example above you select the LAST_NAME
of the root entity and expand the navigation property FRIENDS
.
In the expanded entity the selected fields are FIRST_NAME
and ADDRESS_INFO
.
The generated URL for this request will be:
/People?$select=LastName&$expand=Friends($select=FirstName,AddressInfo)
If no select
is given, all non-navigational properties are included in the response.
Sub-Queries in Expand
Note that you can create very complex queries within the expand scope:
People.requestBuilder()
.getAll()
.expand(
People.FRIENDS.select(People.FIRST_NAME, People.ADDRESS_INFO)
.filter(People.LAST_NAME.equals('Miller'))
.orderBy(asc(People.GENDER))
.top(1)
.skip(1)
);
In this example, the filter will reduce the number of friends to be shown.
The effect of a filter depends on whether it is used inside or outside an expand
.
The different cases are explained in Filters in Expand.
The URL for the query will be:
/People?$expand=Friends($select=FirstName,AddressInfo;$filter=(LastName eq 'Miller');$skip=1;$top=1;$orderby=Gender asc)
Filter Parent vs Filter Children
Depending on the context of the filter it will either filter the parent or the children.
In our example, we have a PERSON
related to zero to N FRIENDS
which are both of type people.
If you want to get all people with first name John
the query is:
People.requestBuilder().getAll().filter(People.FIRST_NAME.equals('John'));
If you want to get all people who have at least one friend with the first name John
the query is:
People.requestBuilder()
.getAll()
.filter(People.FRIENDS.filter(any(People.FIRST_NAME.equals('John'))));
The lambda all
would enforce that all friends must have the first name John
.
The two queries above filter the parent entity person.
In case you want to get all people but reduce the friends in the response, the filter has to be inside the expand:
People.requestBuilder()
.getAll()
.expand(People.FRIENDS.filter(People.FIRST_NAME.equals('John')));
This will return all people but only the friends with the first name John
will be included in the response.
Skip
skip
allows you to skip a number of results in the requested set.
It can be useful for paging:
BusinessPartner.requestBuilder().
.getAll()
.skip(10)
Top
top
limits the number of returned results.
This can also be useful for paging:
BusinessPartner.requestBuilder().getAll().top(10);
The example above retrieves the first ten BusinessPartner
entities.
Count
The method count()
allows you to get the number of elements in a collection.
It is only available for getAll()
requests and is added before the request execution:
BusinessPartner.requestBuilder().getAll().count();
The return type of count requests is a Promise<number>
.
You can combine the count()
with filter conditions.
To get the number of business partners with first name John
execute the following request:
BusinessPartner.requestBuilder()
.filter(BusinessPartner.FIRST_NAME.equals('John'))
.count()
.getAll();
As defined in the OData spec count
is not affected by top
, skip
, and orderBy
.
top()
and skip()
are ignored for countIf you include these methods in a count request they will be ignored by the SAP Cloud SDK. These three requests will all return the same value.
BusinessPartner.requestBuilder().getAll().top(5).count();
BusinessPartner.requestBuilder().getAll().skip(5).count();
BusinessPartner.requestBuilder().getAll().count();
Note that we currently do not support the usage of count within a filter condition as described in the OData v4 specification.
GetByKey
Request Builder
The GetByKey
request builder allows you to create a request to retrieve one entity based on its key:
BusinessPartner.requestBuilder().getByKey('id');
The example above retrieves the BusinessPartner
with the ID 'id'
.
The result can be restricted by applying the select function, same as in the GetAll request.
Create Request Builder
The Create request builder allows you to send a POST
request to create a new entity:
const businessPartner = BusinessPartner.builder().build();
BusinessPartner.requestBuilder().create(businessPartner);
In the example above we created an instance of BusinessPartner
and sent it to the BusinessPartner
service in a POST
request.
Deep Create
It is also possible to create an entity together with related entities in a single request:
// build a business partner instance with one linked address
const businessPartner = BusinessPartner.builder()
.firstName('John')
.lastName('Doe')
.businessPartnerCategory('1')
.toBusinessPartnerAddress([
BusinessPartnerAddress.builder()
.country('DE')
.postalCode('14469')
.cityName('Potsdam')
.streetName('Konrad-Zuse-Ring')
.houseNumber('10')
.build()
])
.build();
// execute the create request
BusinessPartner.requestBuilder().create(businessPartner).execute(myDestination);
When you try the example code above for testing the deep create
feature, you might see some errors like "operation module BUA_CHECK_ADDRESS_VALIDITY_ALL; a check table is missing"
.
Typically, it can happen if you are using a new system with a default configuration.
You need to configure an address usage
field as shown in the example below to fix it.
const businessPartner = BusinessPartner.builder()
.firstName('John')
.lastName('Doe')
.businessPartnerCategory('1')
.toBusinessPartnerAddress([
BusinessPartnerAddress.builder()
.country('DE')
.postalCode('14469')
.cityName('Potsdam')
.streetName('Konrad-Zuse-Ring')
.houseNumber('10')
// additional code starts
.toAddressUsage([
BuPaAddressUsage.builder().addressUsage('XXDEFAULT').build()
])
.build()
])
.build();
You can also create an entity asChildOf
another entity.
Create as Child Of
Assume you have already created a business partner and would like to add a new address to it:
const existingBusinessPartner = await BusinessPartner.requestBuilder()
.getByKey(myID)
.execute(myDestination);
const newAddress = new BusinessPartnerAddress()
.builder()
.country('DE')
.postalCode('14469')
.cityName('Potsdam')
.streetName('Konrad-Zuse-Ring')
.houseNumber('10')
.build();
This can be done by using the asChildOf
method which allows for creating an entity as a child of an existing entity.
You need to give the parent object and the field connecting the two entities:
BusinessPartnerAddress.requestBuilder()
.create(newAddress)
.asChildOf(
existingBusinessPartner,
BusinessPartner.TO_BUSINESS_PARTNER_ADDRESS
)
.execute(myDestination);
Update Request Builder
The update request builder allows you to change existing entities.
By default, PATCH
is used to only update changed fields.
In the following example, we first get a business partner, change one of its values, and then send an update request.
// Get a business partner
const businessPartner = await BusinessPartner.requestBuilder()
.getByKey('1')
.execute({ destinationName: 'myDestination' });
// Change first name
businessPartner.firstName = 'Steve';
// Send a PATCH request with `{ "FirstName" : "Steve" }`
BusinessPartner.requestBuilder().update(businessPartner);
In the example above we changed the first name of the given business partner. The payload sent to the service with PATCH includes only the first name.
Be aware that update requests will fail if their ETags don't match. Check out our ETag section for more information.
Replace the Entity With PUT
To replace an entity instead of just updating specific fields, use replaceWholeEntityWithPut
:
BusinessPartner.requestBuilder()
.update(businessPartner)
.replaceWholeEntityWithPut();
This will send a PUT
request and include all existing properties on your entity, whether they were changed or not.
Set Required Fields
If you want to send properties in the payload of the update request, that you did not change, use the setRequiredFields
method to add them.
In the following example, the PATCH
request will contain the FIRST_NAME
property of the business partner, even if it didn't change.
// Get a business partner
const businessPartner = await BusinessPartner.requestBuilder()
.getByKey('1')
.execute({ destinationName: 'myDestination' });
// Change last name
businessPartner.lastName = 'Smith';
// Send a PATCH request with `{ "LastName" : "Smith", "FirstName": "Steve" }` and include the first name although it was not changed
BusinessPartner.requestBuilder()
.update(businessPartner)
.setRequiredFields(BusinessPartner.FIRST_NAME);
Set Ignored Fields
If you changed properties on an entity, that you do not want to send in the payload of the update request, use the setIgnoredFields
method to ignore them.
In the following example, the PATCH
request won't contain changes that were made to the FIRST_NAME
property of the business partner.
// Get a business partner
const businessPartner = await BusinessPartner.requestBuilder()
.getByKey('1')
.execute({ destinationName: 'myDestination' });
// Change first name and last name
businessPartner.firstName = 'Steve';
businessPartner.lastName = 'Smith';
// Send a PATCH request with `{ "LastName" : "Smith" }` and do not include the changed first name
BusinessPartner.requestBuilder()
.update(businessPartner)
.setIgnoredFields(BusinessPartner.FIRST_NAME);
Delete Request Builder
The Delete request builder allows you to create DELETE
requests, that delete an entity.
/*
The following won't work on the real SAP S/4HANA BusinessPartner service because BusinessPartners cannot be deleted.
We added this only for the sake of the example.
*/
BusinessPartner.requestBuilder().delete(businessPartner);
Be aware that delete requests will fail if their ETag doesn't match. Check out our ETag section for more information.
Entity Builder
To build an entity by assigning its properties, you can use the entity builders provided by every entity class.
Use the static .builder
method to access the builder, set the properties and finally use the .build
method to yield the entity.
To set navigation properties, that link to other entities, you have to create the linked entities using their respective builder.
Navigation properties, that are linked through a one-to-many relation have to be assigned in an array - one-to-one relations are assigned as single objects.
The example below shows how you can create the data from above using the entity builder.
The relation to the business partner address is a one-to-many relation here.
import {
BusinessPartner,
BusinessPartnerAddress
} from '@sap/cloud-sdk-vdm-business-partner-service';
const businessPartner = BusinessPartner.builder()
.firstName('Peter')
.lastName('Pan')
.toBusinessPartnerAddress([
BusinessPartnerAddress.builder().country('Neverland').build()
])
.build();
You can also add fields, that are unknown according to the specification, if you add them as custom fields.
To achieve this, pass an object the .withCustomFields
method, where the keys denote the names of the custom fields, and the values their respective values.
import { BusinessPartner } from '@sap/cloud-sdk-vdm-business-partner-service';
const businessPartner = BusinessPartner.builder()
.firstName('Peter')
.withCustomFields({
myCustomField: 'this is custom'
})
.build();
Building an Entity From JSON
Sometimes, it makes sense for you to store your data as a JSON object, that is based on the entity type. For example, when using the property names of the entity class as properties of your object. If you are looking for a way to create an entity from a JSON response, that you got from an OData service, you are probably looking for entity deserialization.
This would be the JSON representation of a business partner in the SAP Cloud SDK:
{
"firstName": "Peter",
"lastName": "Pan",
"toBusinessPartnerAddress": [
{
"country": "Neverland"
}
]
}
You can use this data to build an entity using the .fromJson
method.
The example below shows how you would create an instance of the business partner class using the .fromJson
method.
import { BusinessPartner } from '@sap/cloud-sdk-vdm-business-partner-service';
const businessPartner = BusinessPartner.builder().fromJson({
firstName: 'Peter',
lastName: 'Pan',
toBusinessPartnerAddress: [
{
country: 'Neverland'
}
]
});
If there are unknown fields present in the JSON object, they will be treated as custom fields.
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.
Consider the following example:
async function modifyBusinessPartner(id) {
const destination = { url: 'https://my.s4-system.com' };
const businessPartner = await BusinessPartner.requestBuilder()
.getByKey(id)
.execute(myDestination);
// do some modification
applyModification(businessPartner);
return BusinessPartner.requestBuilder()
.update(businessPartner)
.execute(destination);
}
When executing getAll
and getByKey
requests, the SAP Cloud SDK will automatically attempt to extract the version identifier from the response and store it within the returned entity (partner
in the example above).
When executing update
requests, the version identifier will be sent in the If-match
request 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.
By default, an ETag is sent if it's present on the entity being modified.
ignoreVersionIdentifier()
will instead always send a *
which acts as a wildcard to match all ETags.
Ignore or Overwrite the Version Identifier
Entities can only be updated or deleted if ETags match.
If you want to force either an update or the deletion of the entity regardless of the ETag, configure the request to ignore version identifiers with ignoreVersionIdentifier
.
Below is an example with an update:
BusinessPartner.requestBuilder()
.update(businessPartner)
.ignoreVersionIdentifier();
You can also overwrite ETags using setVersionIdentifier
:
BusinessPartner.requestBuilder()
.update(businessPartner)
.setVersionIdentifier('etag');
In the example above, the ETag etag
is used instead of the original one.
Handling of Cross-Site Request Forgery Tokens
To create, update, and delete requests the SAP Cloud SDK will try to send a 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.
Skip CSRF Token Handling
For some services, the CSRF
token is not required even for non-get requests.
Therefore, skipping fetching the CSRF
token makes sense as a performance improvement.
You can disable the CSRF
token request by using skipCsrfTokenFetching()
like below:
BusinessPartner.requestBuilder()
.update(businessPartner)
.skipCsrfTokenFetching();
Available Filter Expressions
Filter Functions
There are predefined filter functions e.g. length
, substring
, substringOf
in the core library, that allow for a wide range of filter expressions:
/*
Fetch all business partners who have a first name shorter than 5 letters
*/
BusinessPartner.requestBuilder()
.getAll()
.filter(length(BusinessPartner.FIRST_NAME).lessThan(5))
.execute(destination);
For filter functions with boolean
as the return type, the filter function can be used directly as a filter without .equal(true)
.
Logically, the two following examples are equivalent to each other:
/*
$filter=startswith(FirstName, 'Bob') eq true
*/
.filter(
startsWith(BusinessPartner.FIRST_NAME, 'Bob').equal(true)
)
The filter expression can be shortened:
/*
$filter=startswith(FirstName, 'Bob')
*/
.filter(
startsWith(BusinessPartner.FIRST_NAME, 'Bob')
)
However, as some services might not support both versions shown above, you might have to choose one of them to fit the target system.