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
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
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 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 theserialize
function, which can optionally be used as a basis for URI serialization. If this function is not specified, the URI serialization defaults to theserialize
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();
GetAll Request Builder
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 we mimic this behavior for select
and expand
operations in our API for OData v4 type-safe client.
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:
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
.
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)