Skip to main content

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. You will use the People service from the OData v4 tutorial and the Business Partner Service from the SAP S/4HANA ERP suite.

note

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'
Custom fields are not serialized or deserialized

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

The API class provides an entity builder with the entityBuilder() method. You can set the properties using a fluent API. The build() method will return the new entity.

import { businessPartnerService } from './generated/business-partner-service';

const { businessPartnerApi, businessPartnerAddressApi } =
businessPartnerService();

businessPartnerApi.entityBuilder().firstName('Peter').lastName('Pan').build();

You can create navigation properties that link to other entities using their respective builders. Navigation properties with a one-to-many relation require an array of entities as a parameter. This also applies if the array only contains one entity.

The relation to the business partner address is a one-to-many relation here.

import { businessPartnerService } from './generated/business-partner-service';

const { businessPartnerApi, businessPartnerAddressApi } =
businessPartnerService();

businessPartnerApi
.entityBuilder()
.firstName('Peter')
.lastName('Pan')
.toBusinessPartnerAddress([
businessPartnerAddressApi.entityBuilder().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 to the withCustomFields() method. The keys of the object represent the names of the custom fields and the values their respective values.

import { businessPartnerService } from './generated/business-partner-service';

const { businessPartnerApi } = businessPartnerService();

businessPartnerApi
.entityBuilder()
.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 { businessPartnerService } from './generated/business-partner-service';

const { businessPartnerApi } = businessPartnerService();
const businessPartner = businessPartnerApi.entityBuilder().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() method. 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 odata-v2 or odata-v4 package.

import {
defaultDeSerializers,
entityDeserializer
} from '@sap-cloud-sdk/odata-v2';
import { businessPartnerService } from './generated/business-partner-service';

const { businessPartnerApi } = businessPartnerService();
const originalEntity = {
FirstName: 'Peter',
LastName: 'Pan',
to_BusinessPartnerAddress: [
{
Country: 'Neverland'
}
]
};

const deserializedEntity = entityDeserializer(
defaultDeSerializers
).deserializeEntity(originalEntity, businessPartnerApi);

Customize (De-)Serialization

Since version 2 of the SAP Cloud SDK you can customize how the data you get from a service is deserialized and serialize when sending it back to the service.

To influence (de-)serialization you have to provide your custom (de-)serializers to a service.

A (de-)serializer is an object of type DeSerializer that defines the following callback functions:

  • deserialize(): Takes a value as given by the service and returns a deserialized value, i.e. its represenation in code.
  • serialize(): Takes a deserialized value and transforms it to the format and type expected by the service.
  • serializeToUri() (optional): For some EDM types the serialized format differs between values in a payload and URI. This function takes a deserialized value and transforms it to a string with the format expected by the service for URIs. The second parameter of this callback function references the serialize() function, which can optionally be used as a basis for URI serialization. If this function is not specified, the URI serialization defaults to the serialize() function.

The type DeSerializer has one generic parameter, that represents the deserialized type.

Example:

const doubleDeSerializer: DeSerializer<number> = {
deserialize: (val: string) => Number(val),
serialize: (val: number) => val.toString(),
serializeToUri: (value, serialize) => `${serialize(value)}D`
};

The example above shows a simplified version of the SAP Cloud SDK default (de-)serializer for the EDM type Edm.Double:

  • The deserialize() function converts the raw value to a number.
  • The serialize() function converts the deserialized value to a string.
  • The serializeToUri() function makes use of the serialize() function and appends a D at the end of the string.

To specify custom (de-)serializers for a service, you have to pass an object to the service function, that maps from EDM type(s) to your custom (de-)serializer. All unspecified EDM types are still (de-)serialized using the SAP Cloud SDK defaults.

Example, using the above doubleDeSerializer for the business partner service:

const customDeSerializers = { 'Edm.Double': doubleDeSerializer };
const { businessPartnerApi } = businessPartnerService(customDeSerializers);

All requests against the businessPartnerApi will now use the custom (de-)serializers for Edm.Double and the default (de-)serializers for all other EDM types.

Using (De-)Serializers for Temporal

Temporal is a stage 3 proposal for a date/time API in ECMAScript. At the time, there is polyfill available, but it is not recommended for productive use. Once it is recommended for productive use, the SAP Cloud SDK will adapt it as the default over Moment.js. Temporal-based (de-)serializers for the SAP Cloud SDK are available as a separate npm package, @sap-cloud-sdk/temporal-de-serializers.

Adapt your code to use this package, for example:

import { temporalDeSerializersV2 } from '@sap-cloud-sdk/temporal-de-serializers';
const { businessPartnerApi } = businessPartnerService(temporalDeSerializersV2);

businessPartnerApi
.entityBuilder()
.organizationFoundationDate(
Temporal.PlainDateTime.from('1995-12-07T03:24:30')
)
.build();

GetAllRequestBuilder

The GetAllRequestBuilder class allows you to create a request to retrieve all entities that match the request configuration.

const { businessPartnerApi } = businessPartnerService();
businessPartnerApi.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:

const { businessPartnerApi } = businessPartnerService();
businessPartnerApi
.requestBuilder()
.getAll()
.select(
businessPartnerApi.schema.FIRST_NAME,
businessPartnerApi.schema.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.

Filters are built via the fields available on the schema property of the entities API class:

/*
Get all business partners that either:
- Have first name 'Alice' but not last name 'Bob'
- Or have first name 'Mallory'
*/
const { businessPartnerApi } = businessPartnerService();
const { or, and } = require('@sap-cloud-sdk/odata-v2');

businessPartnerApi
.requestBuilder()
.getAll()
.filter(
or(
and(
businessPartnerApi.schema.FIRST_NAME.equals('Alice'),
businessPartnerApi.schema.LAST_NAME.notEquals('Bob')
),
businessPartnerApi.schema.FIRST_NAME.equals('Mallory')
)
)
.execute(destination);

The EDM primitive types support all six comparison operators:

  • lessThan()
  • lessOrEqual()
  • equals()
  • notEquals()
  • greaterOrEqual()
  • greaterThan()

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(
businessPartnerApi.schema.FIRST_NAME.equals('Alice'),
businessPartnerApi.schema.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(
businessPartnerApi.schema.FIRST_NAME.equals('Alice'),
businessPartnerApi.schema.LAST_NAME.notEquals('Bob')
)
)

The example above can be shortened to:

.filter(
businessPartnerApi.schema.FIRST_NAME.equals('Alice'),
businessPartnerApi.schema.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(
businessPartnerApi.schema.TO_CUSTOMER.filter(
customerApi.schema.CUSTOMER_NAME.equals('John'),
customerApi.schema.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 of the following conditions:
- Has first name 'John'
- Has last name 'Miller'
*/
.filter(
peopleApi.schema.FRIENDS.filter(
any(
peopleApi.schema.FIRST_NAME.equals('John'),
peopleApi.schema.LAST_NAME.equals('Miller')
)
)
)

The generated $filter parameter of the URL will be:

$filter=((Friends/any(a0:(a0/FirstName eq 'John' and a0/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 the SAP Cloud SDK mimics this behavior for select and expand operations in its API for OData v4 type-safe clients.

In the example above you select the LAST_NAME field 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 operation 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:

const { peopleApi } = peopleService();
peopleApi
.requestBuilder()
.getAll()
.expand(
peopleApi.schema.FRIENDS.select(
peopleApi.schema.FIRST_NAME,
peopleApi.schema.ADDRESS_INFO
)
.filter(peopleApi.schema.LAST_NAME.equals('Miller'))
.orderBy(asc(peopleApi.schema.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() method. 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 the example, a PERSON entity relates to zero to N FRIENDS entities which are both of type people.

If you want to get all people with first name John the query is:

const { peopleApi } = peopleService();
peopleApi
.requestBuilder()
.getAll()
.filter(peopleApi.schema.FIRST_NAME.equals('John'));

If you want to get all people who have at least one friend with first name John, the query is:

const { peopleApi } = peopleService();
peopleApi
.requestBuilder()
.getAll()
.filter(
peopleApi.schema.FRIENDS.filter(
any(peopleApi.schema.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:

const { peopleApi } = peopleService();
peopleApi
.requestBuilder()
.getAll()
.expand(
peopleApi.schema.FRIENDS.filter(peopleApi.schema.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

The skip() method allows you to skip a number of results in the requested set. It can be useful for paging:

const { businessPartnerApi } = businessPartnerService();
businessPartnerApi.requestBuilder().getAll().skip(10);

Top

The top() method limits the number of returned results. This can also be useful for paging:

const { businessPartnerApi } = businessPartnerService();
businessPartnerApi.requestBuilder().getAll().top(10);

The example above retrieves the first ten BusinessPartner entities.

OrderBy

The orderBy() method allows you to request resources in either ascending order using asc() or descending order using desc(). If asc() or desc() is not specified, then the resources will be ordered in ascending order by default.

const { businessPartnerApi } = businessPartnerService();
businessPartnerApi
.requestBuilder()
.getAll()
.orderBy(asc(businessPartnerApi.schema.FIRST_NAME));

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:

const { businessPartnerApi } = businessPartnerService();
businessPartnerApi.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:

const { businessPartnerApi } = businessPartnerService();
businessPartnerApi
.requestBuilder()
.filter(businessPartnerApi.schema.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 count

If 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.

businessPartnerApi.requestBuilder().getAll().top(5).count();
businessPartnerApi.requestBuilder().getAll().skip(5).count();
businessPartnerApi.requestBuilder().getAll().count();
Using count within a filter is not supported

Note that the SAP Cloud SDK does not support the usage of count within a filter condition as described in the OData v4 specification.

GetByKeyRequestBuilder

The GetByKeyRequestBuilder class allows you to create a request to retrieve one entity based on its key:

const { businessPartnerApi } = businessPartnerService();
businessPartnerApi.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.

CreateRequestBuilder

The CreateRequestBuilder class allows you to send a POST request to create an entity:

const { businessPartnerApi } = businessPartnerService();
const businessPartner = businessPartnerApi.entityBuilder().build();
businessPartnerApi.requestBuilder().create(businessPartner);

In the example above you 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 { businessPartnerApi, businessPartnerAddressApi } =
businessPartnerService();
const businessPartner = businessPartnerApi
.entityBuilder()
.firstName('John')
.lastName('Doe')
.businessPartnerCategory('1')
.toBusinessPartnerAddress([
businessPartnerAddressApi
.entityBuilder()
.country('DE')
.postalCode('14469')
.cityName('Potsdam')
.streetName('Konrad-Zuse-Ring')
.houseNumber('10')
.build()
])
.build();

// execute the create request
businessPartnerApi
.requestBuilder()
.create(businessPartner)
.execute(myDestination);
Troubleshooting

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. Call the addressUsage() method as shown in the example below to fix it.

const { businessPartnerApi, businessPartnerAddressApi, buPaAddressUsageApi } =
businessPartnerService();
businessPartnerApi
.entityBuilder()
.firstName('John')
.lastName('Doe')
.businessPartnerCategory('1')
.toBusinessPartnerAddress([
businessPartnerAddressApi
.entityBuilder()
.country('DE')
.postalCode('14469')
.cityName('Potsdam')
.streetName('Konrad-Zuse-Ring')
.houseNumber('10')
// additional code starts
.toAddressUsage([
buPaAddressUsageApi.entityBuilder().addressUsage('XXDEFAULT').build()
])
.build()
])
.build();

You can also create an entity as a child of another entity with the asChildOf() method.

Create as Child Of

Assume you have already created a business partner and would like to add an address to it:

const { businessPartnerApi, businessPartnerAddressApi } =
businessPartnerService();
const existingBusinessPartner = await businessPartnerApi
.requestBuilder()
.getByKey(myID)
.execute(myDestination);

const newAddress = businessPartnerAddressApi
.entityBuilder()
.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:

businessPartnerAddressApi
.requestBuilder()
.create(newAddress)
.asChildOf(
existingBusinessPartner,
businessPartnerApi.schema.TO_BUSINESS_PARTNER_ADDRESS
)
.execute(myDestination);

UpdateRequestBuilder

The update request builder allows you to change existing entities. By default, PATCH is used to only update changed fields.

The following example first gets a business partner, changes one of its values, and then sends an update request.

const { businessPartnerApi } = businessPartnerService();

// Get a business partner
const businessPartner = await businessPartnerApi
.requestBuilder()
.getByKey('1')
.execute({ destinationName: 'myDestination' });

// Change first name
businessPartner.firstName = 'Steve';

// Send a PATCH request with `{ "FirstName" : "Steve" }`
businessPartnerApi.requestBuilder().update(businessPartner);

The code above 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 the ETag section for more information.

Replace the Entity With PUT

To replace an entity instead of just updating specific fields, use the replaceWholeEntityWithPut() method:

const { businessPartnerApi } = businessPartnerService();

businessPartnerApi
.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.

const { businessPartnerApi } = businessPartnerService();
// Get a business partner
const businessPartner = await businessPartnerApi
.requestBuilder()
.getByKey('1')
.execute({ destinationName: 'myDestination' });

// Change last name
businessPartnerResponse.lastName = 'Smith';

// Send a PATCH request with `{ "LastName" : "Smith", "FirstName": "Steve" }` and include the first name although it was not changed
businessPartnerApi
.requestBuilder()
.update(businessPartner)
.setRequiredFields(businessPartnerApi.schema.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.

const { businessPartnerApi } = businessPartnerService();
// Get a business partner
const businessPartner = await businessPartnerApi
.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
businessPartnerApi
.requestBuilder()
.update(businessPartner)
.setIgnoredFields(businessPartnerApi.schema.FIRST_NAME);

DeleteRequestBuilder

The DeleteRequestBuilder class 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.
This is added only for the sake of the example.
*/
const { businessPartnerApi } = businessPartnerService();
businessPartnerApi.requestBuilder().delete(businessPartner);

Be aware that delete requests will fail if their ETag doesn't match. Check out the ETag section for more information.

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:

const { businessPartnerApi } = businessPartnerService();

async function modifyBusinessPartner(id) {
const businessPartner = await businessPartnerApi
.requestBuilder()
.getByKey(id)
.execute(destination);

// do some modification
applyModification(businessPartner);

return businessPartnerApi
.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.

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.

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 the ignoreVersionIdentifier() method.

Below is an example with an update:

const { businessPartnerApi } = businessPartnerService();
businessPartnerApi
.requestBuilder()
.update(businessPartner)
.ignoreVersionIdentifier();

You can also overwrite ETags using method setVersionIdentifier():

const { businessPartnerApi } = businessPartnerService();
businessPartnerApi
.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 to fetch the CSRF token makes sense as a performance improvement. You can disable the CSRF token request by using the skipCsrfTokenFetching() method like below:

const { businessPartnerApi } = businessPartnerService();
businessPartnerApi
.requestBuilder()
.update(businessPartner)
.skipCsrfTokenFetching();

Custom CSRF Token Handling

If you need to adjust the way CSRF tokens are fetched, you can do so by using middlewares:

  • Disable the token fetching to deactivate the default code
  • Add a middleware to include your custom token fetching.

The SAP Cloud SDK offers a csrf middleware which allows to configure some basic options:

  • method The HTTP method used to get a token
  • URL The URL which is called to retrieve a token
  • middleware Middlewares used for the token retrieval request
const { businessPartnerApi } = businessPartnerService();
businessPartnerApi
.requestBuilder()
.update(businessPartner)
.middleware([csrf({ url: 'https://example.com/csrf/token/url' })])
.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
*/
const { businessPartnerApi } = businessPartnerService();
businessPartnerApi
.requestBuilder()
.getAll()
.filter(length(businessPartnerApi.schema.FIRST_NAME).lessThan(5))
.execute(destination);

Filter functions with return type boolean 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(businessPartnerApi.schema.FIRST_NAME, 'Bob').equal(true)
)

The filter expression can be shortened:

  /*
$filter=startswith(FirstName, 'Bob')
*/
.filter(
startsWith(businessPartnerApi.schema.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.

Operations (Functions and Actions)

OData supports operations, called actions and functions.

Actions may have side-effects, functions may not have side-effects.

Actions and functions work quite similar but have differences in some details. In the following, operations refers to something that applies both to functions and actions.

Operations can be bound or unbound. Bound means that the operation is tied to an entity. This is similar to how a method is tied to a class instance in object oriented languages, while functions or "static" methods are not tied to an instance.

To learn how to generate an API client from the service definition, look at this article.

Using Operations

Bound operations are generated into their respective entity class.

They can be called on the entity class like in the following example:

const { catalogApi } = catalogService();

const entity = catalogApi.requestBuilder().getByKey(42).execute(destination);

entity.boundActionWithoutArguments({}).execute(destination);
entity
.boundActionWithArguments({ argument1: 'foo', argument2: 'bar' })
.execute(destination);

entity.boundFunctionWithoutArguments({}).execute(destination);
entity
.boundFunctionWithArguments({ argument1: 'foo', argument2: 'bar' })
.execute(destination);

Unbound actions and functions are generated into operations.

The following code snippet shows the syntax for using them:

const {
operations: {
unboundActionWithArguments,
unboundActionWithoutArguments,
unboundFunctionWithArguments,
unboundFunctionWithoutArguments
}
} = catalogService();

unboundActionWithoutArguments({}).execute(destination);
unboundActionWithArguments({ argument1: 'foo', argument2: 'bar' }).execute(
destination
);

unboundFunctionWithoutArguments({}).execute(destination);
unboundFunctionWithArguments({ argument1: 'foo', argument2: 'bar' }).execute(
destination
);

Response Transformation

If the response structure does not match the transformation type, the promise from execute(destination) will be resolved into undefined.

The default transformer expects the following response format:

{
"d": {
"Count": 0
}
}

Assume the actual response has an additional layer Foo:

{
"d": {
"Foo": {
"Count": 0
}
}
}

In this case, dataAccessor can be used in the following way to modify the response into the desired form for further deserialization.

functionImportRequestBuilder.execute(destination, data => data.d.Foo);

Setting ETag

Operations do not support ETag handling automatically. If your service requires the version identifier of an entity to be present, you can pass it to the request on your own using the addCustomHeaders() method (see example below). With this approach, you have to retrieve the required entity with a GET request, before executing the function import. Note, that if the function import execution succeeds, the version identifier of your entity will be outdated.

myFunction()
.addCustomHeaders({ 'if-match': entity.versionIdentifier })
.execute(destination);
Troubleshooting

For some OData functions or action imports, the execute() method might be missing as intended. Typically, this might happen when an entity type is shared by multiple entity sets and is used as the return type of OData function or action imports. In such a case you can use executeRaw() method for getting the raw response returned after invoking the OData function or action via type-safe API and deserialize it on your own.

Known Issues

  1. Currently, the Entity Type is not supported to be used as the parameters of the operation. Operations with such unsupported parameters are ignored during the generation. This feature will be implemented in the future. Please check this issue and comment if you need this feature.

Batch Requests

OData batch requests combine multiple operations into one POST operation, allowing you to execute multiple requests with just one network call. This can significantly reduce the network overhead you have to deal with when you want to execute many requests.

Every SAP Cloud SDK client provides a batch() function that takes retrieve requests and changesets. You can combine those arbitrarily. To execute batch requests, use the execute() method - it works the same way as for single requests.

The response of a batch request is a list of BatchResponse instances. Each of the items in the response is either a ReadResponse, WriteResponses or an ErrorResponse, see Responses.

Retrieve Request

A retrieve request represents an HTTP GET request. In terms of the SAP Cloud SDK this includes all requests built by GetAllRequestBuilder, GetByKeyRequestBuilder and FunctionImportRequestBuilder.

You can pass retrieve requests directly to the batch() function. Once you execute a batch request you get a list of BatchResponse. A BatchResponse that corresponds with a retrieve request can either be a ReadResponse or an ErrorResponse.

In the example below, you map each given address ID to a GetByKeyRequestBuilder. These retrieve requests are combined into one batch request and executed against a destination.

The example below is based on the Bills of Material service, which contains a function import called getPdf(). This service is an OData v2 service, but the syntax is the same for OData v2 and v4.

const {
billingDocumentApi,
operations: { getPdf }
} = billingDocumentService();

async function batchExample(documentIds: string[]) {
const getByKeyRequests = documentIds.map(billingDocument =>
billingDocumentApi.requestBuilder().getByKey(billingDocument)
);
const functionImportRequests = documentIds.map(billingDocument =>
getPdf({ billingDocument })
);

// Execute batch request combining multiple retrieve requests
const batchResponses = await batch(
...getByKeyRequests,
...functionImportRequests
);
}

Changeset

A changeset is a collection of HTTP POST, PUT, PATCH and DELETE operations. Such operations are built by the CreateRequestBuilders, UpdateRequestBuilders, DeleteRequestBuilders or ActionImportRequestBuilder.

The order of execution within a changeset is not defined. This differs from the whole batch request itself, where the order is defined. Therefore, the requests within one changeset should not depend on each other. If the execution of any request within a changeset fails, the whole changeset will be reflected as an error in the response. The changeset will not be applied, much like a database transaction.

Unlike retrieve requests, you can not pass change requests to the batch() function directly. You have to wrap them with the changeset() function, which in turn can be passed to the batch() function. Once a batch request is executed, it returns a list of BatchResponse objects.

If a changeset was executed successfully, its corresponding response is of type WriteResponses and contains a collection of all raw responses to the requests in the changeset. If the execution fails, the response is an ErrorResponse instance.

In the example below, you create a list of UpdateRequestBuilder instances from a list of addresses. Combine these change requests into one changeset and pass it to the batch request, which you execute against a destination.

Once you execute the batch request, you get a list of BatchResponse instances, which in this example contains one response only, i.e. the one for the changeset.

The example below is based on the Materials Service. Since the action active() changes data, it is included with other changing requests like update in a changeset:

const {
materialsActiveApi,
operations: { activate }
} = materialsService();

async function batchExample(id: string) {
const actionRequestBuilder = activate({});
const updateRequestBuilder = materialsActiveApi
.requestBuilder()
.update(materialsActiveApi.entityBuilder().fromJson({ id }));

// Execute batch request with one changeset
const batchResponses = await batch(
changeset(actionRequestBuilder, updateRequestBuilder)
).execute(destination);

// Get response for the changeset request
const changesetResponse = batchResponses[0];

// ...
}

Responses

Once a batch request is executed, it returns a list of BatchResponse objects. The responses correspond to the retrieve requests and changesets in the same order that they were passed to the batch() function. Requests that were not successful are reflected as a list of ErrorResponse objects. Responses to successful requests are represented as ReadResponse instances for retrieve requests, and WriteResponses for changesets.

To determine the type of a response, you can use the following type guards:

  • isSuccess(): to check that the response is either a ReadResponse or WriteResponses instance
  • isError(): to check that the response is an ErrorResponse instance
  • isReadResponse(): to check that the response is a ReadResponse instance
  • isWriteResponses(): to check that the response is a WriteResponses instance

Then, depending on the corresponding request, you can parse the response or handle the error.

ReadResponse

Successful retrieve requests have the type ReadResponse, which contains the HTTP code, the raw body, and the constructor of the entity that was parsed from the response. To work with an instance of the retrieved entity, you can use the as() method, which allows you to transform the raw data into an instance of an entity represented by the given entity API. Note, that the as() method transforms the raw data to an array of entities, even if the original request was a GetByKeyRequestBuilder.

In the example below, you combine a list of GetByKeyRequestBuilder instances into one batch request and execute it against a destination. If one of the requests was unsuccessful, an error will be thrown. Otherwise, the responses are transformed into instances of BusinessPartnerAddress.

// Destructure business partner service
const { batch, businessPartnerAddressApi } = businessPartnerService();

async function getAddressesByIds(
...retrieveRequests: GetByKeyRequestBuilder<BusinessPartnerAddress>[]
): Promise<BusinessPartnerAddress[]> {
// Execute batch request combining multiple retrieve requests
const batchResponses = await batch(...retrieveRequests).execute(destination);

// Error handling
if (batchResponses.some(response => !response.isSuccess())) {
throw new Error('Some of the batch subrequests were not successful.');
}

return batchResponses.reduce(
(addresses: BusinessPartnerAddress[], response: BatchResponse) => {
if (response.isReadResponse()) {
// Transform response to an instance of BusinessPartnerAddress
const [address] = response.as(businessPartnerAddressApi);
addresses.push(address);
}
return addresses;
},
[]
);
}

Function imports using GET operation are part of the retrieve requests as well. As function imports are not entity based, you obtain the response using the responseTransformer() method, which is part of the FunctionImportRequestBuilder:

const {
operations: { getPdf }
} = billingDocumentService();

async function batchExample(billingDocument: string) {
const request = getPdf({ billingDocument });
const [response] = await batch(request).execute(destination);
if (response.isReadResponse()) {
const parsedResponse = request.responseTransformer(response.body);
}
}

WriteResponses

Successful changeset requests can be cast to type WriteResponses which contains all subresponses for the changeset request. Those responses can be accessed with the responses property and have the type WriteResponse. Each WriteResponse instance contains the HTTP code and can contain the raw body and the constructor of the entity that was parsed from the response, depending on whether there was a body in the response. Create and delete requests typically do not have a response body. To work with an instance of an entity given in a WriteResponse, you can use the as() method, which allows you to transform the raw string body into an instance of the given constructor. Note that the response may not exist, so you should only call this method if you know that there is data. Typically the HTTP code is a good indicator for this - 201 No Content probably won't have content.

If you are working with TypeScript, you will have to tell the compiler that the as!() method can be used here by adding a !. Also note, that the response to a changeset can be an ErrorResponse. Therefore, it is crucial to check responses for success, before casting them to WriteResponses.

In the example below, you combine a list of UpdateRequestBuilder instances into one changeset and pass it to the batch request. Once you execute the batch request, you get a list which contains one BatchResponse object only.

If the request was unsuccessful, an error will be thrown. Otherwise, the subresponses are transformed into instances of BusinessPartnerAddress.

// Destructure business partner service
const { batch, changeset } = businessPartnerService();

async function updateAddresses(
...updateRequests: UpdateRequestBuilder<BusinessPartnerAddress>
): Promise<BusinessPartnerAddress[]> {
// Execute batch request with one changeset
const batchResponses = await batch(
// Combine update requests into one changeset
changeset(...updateRequests)
).execute(destination);

// Get response for the changeset request
const changesetResponse = batchResponses[0];

if (changesetResponse.isWriteResponses()) {
return changesetResponse.responses.map(response =>
// Transform response to an instance of BusinessPartnerAddress
response.as!(BusinessPartnerAddress)
);
}

// Error handling
throw new Error('The changeset request was not successful.');
}

As discussed for the read response you have to use the responseTransformer() method for write responses related to function or action imports.

ErrorResponse

Unsuccessful retrieve requests and changesets are reflected as ErrorResponse instances in the response. Those responses have an httpCode (a number) and a body (a JSON object).

Combining Requests

You can combine requests within a batch request as you like. In the example below, you combine create requests, update requests, and get requests into one batch request. Remember, that change requests have to be wrapped in a changeset() function. Put the create requests into one changeset and combine the update and delete requests into another. The GET requests are added to the batch request directly without wrapping.

Once the batch request is executed the response will be an array of sub-responses to the specific changeset and retrieve requests in the order that was given in the request. Note, that the sub-responses of a changeset don't have to be in order.

async function executeComplexBatch(
createAddressRequests: CreateRequestBuilder<BusinessPartnerAddress>[],
updateAddressRequests: UpdateRequestBuilder<BusinessPartnerAddress>[],
deleteAddressRequests: DeleteRequestBuilder<BusinessPartnerAddress>[],
getAddressByIdRequests: GetByKeyRequestBuilder<BusinessPartnerAddress>[]
) {
// Execute batch request
const [
createAddressesResponse,
updateAndDeleteAddressesResponse,
...getAddressesByIdResponses
] = await batch(
changeset(...createAddressRequests),
changeset(...updateAddressRequests, ...deleteAddressRequests),
...getAddressByIdRequests
).execute(destination);

// Do something with responses
}

Serialization

By default, when you execute a batch request, the subrequests are serialized to a multipart representation of the request, which is essentially a string. This is what a create request for a business partner addresses would serialize to:

Content-Type: application/http
Content-Transfer-Encoding: binary

POST /sap/opu/odata/sap/API_BUSINESS_PARTNER/A_BusinessPartnerAddress HTTP/1.1
Content-Type: application/json
Accept: application/json

{"BusinessPartner":"1","PostalCode":"10001","City":"New York","Country":"USA"}

The first lines are request headers for the multipart request, followed by a blank line. The next line contains the request method and URL, followed by the request headers, a blank line, and the request payload. Every "atomic" request is serialized to a string of this kind, while GET and DELETE requests do not provide a payload.

Configure Subrequest Serialization

By default, URLs in the multipart representation of a request are serialized to a path relative to the service, e.g.:

GET /sap/opu/odata/sap/API_BUSINESS_PARTNER/A_BusinessPartnerAddress HTTP/1.1

However, some services might only understand URLs relative to the entity or even absolute URLs.

To configure the serialization of the URLs within a batch request, you can set the subrequest path type using the withSubRequestPathType() method. You can choose from the following options:

  • relativeToService (default), which yields URLs relative to the service
  • relativeToEntity, which yields URLs relative to the entity
  • absolute, which produces absolute URLs
  • noPath, which only returns the entity with no preceding path

See below for examples:

Serialize subrequest path relative to entity:

// GET /A_BusinessPartnerAddress HTTP/1.1
batch(...requests).withSubRequestPathType('relativeToEntity');

Serialize subrequest without a preceding path (like relativetoEntity without a leading slash):

// GET A_BusinessPartnerAddress HTTP/1.1
batch(...requests).withSubRequestPathType('noPath');

Serialize subrequest path as absolute URL:

// GET https://my-s4.system.com/sap/opu/odata/sap/API_BUSINESS_PARTNER/A_BusinessPartnerAddress HTTP/1.1
batch(...requests).withSubRequestPathType('absolute');