
Disclaimer: This blog post is only applicable for the SAP Cloud SDK version of at most 2.19.2. We plan to continuously migrate these blog posts into our List of Tutorials. Feel free to check out our updated Tutorials on the SAP Cloud SDK.
cf version
without an error.mvn archetype:generate -DarchetypeGroupId=com.sap.cloud.s4hana.archetypes -DarchetypeArtifactId=scp-cf-spring -DarchetypeVersion=2.15.0
org.example.sfsf
as base package../application/src/main/resources/api/TimeOff.edmx
./application/pom.xml
file and add the following <plugin>
next to the other declared plugins. <plugins>
...
<plugin>
<groupId>com.sap.cloud.s4hana.datamodel</groupId>
<artifactId>odata-generator-maven-plugin</artifactId>
<version>2.15.0</version>
<executions>
<execution>
<id>generate-consumption</id>
<phase>generate-sources</phase>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<inputDirectory>${project.basedir}/src/main/resources/api</inputDirectory>
<outputDirectory>${project.build.directory}/generated-sources/sfsf-lombok</outputDirectory>
<deleteOutputDirectory>true</deleteOutputDirectory>
<defaultBasePath>odata/</defaultBasePath>
<packageName>org.example.sfsf</packageName>
<serviceNameMappingFile>${project.basedir}/serviceNameMapppings.properties</serviceNameMappingFile>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
./application/pom.xml
file add the next <plugin>
definition:<plugins>
...
<plugin>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-maven-plugin</artifactId>
<version>1.18.6.0</version>
<executions>
<execution>
<id>delombok</id>
<phase>generate-sources</phase>
<goals>
<goal>delombok</goal>
</goals>
<configuration>
<addOutputDirectory>true</addOutputDirectory>
<sourceDirectory>${project.build.directory}/generated-sources/sfsf-lombok</sourceDirectory>
<outputDirectory>${project.build.directory}/generated-sources/sfsf</outputDirectory>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
./application/pom.xml
file. The dependency version does not need to be specified, as it will be automatically derived from the Maven dependencyManagement with SAP Cloud SDK declared parent pom.xml
.<dependencies>
...
<!-- due to OData VDM code -->
<dependency>
<groupId>com.sap.cloud.s4hana.datamodel</groupId>
<artifactId>odata-core</artifactId>
</dependency>
</dependencies>
mvn clean install
target
directory of the application
module:./application/target/generated-sources/sfsf/
services
contains the service classes, in this case only for "TimeOff". The dedicated interface file enables the usage of Beans in Servlet and Spring frameworks. Given the interface, each API features the respective OData operations as service methods.namespace.timeoff
holds static classes for API querying, e.g. fluent helper enabling a type-safe API reference. Inside are further packages to map fields, links and selectors of entities as well as batch helper classes.SuccessFactorsDestination.java
to serve as placeholder for the destination identifier.package org.example.sfsf;
import com.sap.cloud.sdk.cloudplatform.connectivity.DestinationDeclarator;
public class SuccessFactorsDestination extends DestinationDeclarator {
public final static String DESTINATION_NAME = "SFSF";
public SuccessFactorsDestination() {
super(DESTINATION_NAME);
}
}
SuccessFactorsException.java
to handle custom errors when operating SAP SuccessFactors.package org.example.sfsf;
public class SuccessFactorsException extends Exception
{
public SuccessFactorsException( final String msg )
{
super(msg);
}
public SuccessFactorsException( final String msg, Throwable e )
{
super(msg, e);
}
}
Application.java
and add the following method to the class. This will register a default Bean implementation for Clock
.@org.springframework.context.annotation.Bean
public java.time.Clock clock()
{
return java.time.Clock.systemDefaultZone();
}
GetEntitlementsCommand.java
to serve the actual OData requests with SAP SuccessFactors. For a given userId and date span, it resolves the available days of the current user time account. It ensures tenant and user isolated caching.package org.example.sfsf;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.sap.cloud.sdk.cloudplatform.cache.CacheKey;
import com.sap.cloud.sdk.frameworks.hystrix.HystrixUtil;
import com.sap.cloud.sdk.odatav2.connectivity.ODataException;
import com.sap.cloud.sdk.s4hana.connectivity.CachingErpCommand;
import com.sap.cloud.sdk.s4hana.connectivity.ErpConfigContext;
import org.apache.commons.lang.StringUtils;
import org.example.sfsf.namespaces.timeoff.TimeAccount;
import org.example.sfsf.namespaces.timeoff.TimeAccountDetail;
import org.example.sfsf.services.TimeOffService;
import javax.annotation.Nonnull;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
public class GetEntitlementsCommand extends CachingErpCommand<List<TimeAccountDetail>>
{
private static final Cache<CacheKey, List<TimeAccountDetail>> cache =
CacheBuilder.newBuilder().maximumSize(50).expireAfterWrite(7, TimeUnit.DAYS).build();
private static final String ACCOUNT_TYPE_VACATION = "TAT_VAC_REC";
private final TimeOffService service;
private final String userId;
private final LocalDateTime startDate;
private final LocalDateTime endDate;
public GetEntitlementsCommand(
@Nonnull final TimeOffService service,
@Nonnull final String userId,
@Nonnull final LocalDateTime startDate,
@Nonnull final LocalDateTime endDate )
{
super(
HystrixUtil.getDefaultErpCommandSetter(
GetEntitlementsCommand.class,
HystrixUtil
.getDefaultErpCommandProperties()
.withExecutionTimeoutInMilliseconds(10000)
.withFallbackEnabled(false)),
new ErpConfigContext(SuccessFactorsDestination.DESTINATION_NAME));
this.service = service;
this.userId = userId;
this.startDate = startDate;
this.endDate = endDate;
}
@Nonnull
@Override
protected Cache<CacheKey, List<TimeAccountDetail>> getCache()
{
return cache;
}
@Nonnull
@Override
protected CacheKey getCommandCacheKey()
{
return super.getCommandCacheKey().append(userId, startDate, endDate);
}
@Override
@Nonnull
protected List<TimeAccountDetail> runCacheable()
throws ODataException,
SuccessFactorsException
{
final List<TimeAccount> timeAccountList =
service
.withServicePath("odata/v2")
.getAllTimeAccount()
.filter(
TimeAccount.USER_ID
.eq(userId)
.and(TimeAccount.START_DATE.le(endDate))
.and(TimeAccount.END_DATE.ge(startDate))
.and(TimeAccount.ACCOUNT_TYPE.eq(ACCOUNT_TYPE_VACATION))
.and(TimeAccount.ACCOUNT_CLOSED.eq(false)))
.select(
TimeAccount.TO_TIME_ACCOUNT_DETAILS.select(
TimeAccountDetail.ACCRUAL_PERIOD_ID,
TimeAccountDetail.BOOKING_AMOUNT,
TimeAccountDetail.BOOKING_TYPE,
TimeAccountDetail.BOOKING_DATE,
TimeAccountDetail.BOOKING_UNIT,
TimeAccountDetail.CREATED_DATE))
.execute(getConfigContext());
if( timeAccountList.isEmpty() ) {
throw new SuccessFactorsException("No time account found for user " + userId);
}
if( timeAccountList.size() > 1 ) {
throw new SuccessFactorsException("More than one time account found for user " + userId);
}
return timeAccountList
.get(0)
.getTimeAccountDetailsIfPresent()
.orElseThrow(() -> new SuccessFactorsException("Failed to resolve time account items for user " + userId))
.stream()
.filter(d -> !StringUtils.equalsIgnoreCase("ACCRUAL", d.getBookingType()) || checkDate(d.getBookingDate()))
.collect(Collectors.toList());
}
private boolean checkDate( final LocalDateTime t )
{
return t != null
&& (startDate.isBefore(t) || startDate.isEqual(t))
&& (endDate.isAfter(t) || endDate.isEqual(t));
}
}
TimeOffService service
instance. You can find filtering on the TimeAccount collection and selection of expanded fields for TimeAccountDetail.withServicePath(...)
declaration.models/AvailableDaysResponse.java
to hold the numeric value prepared by the controller.package org.example.sfsf.models;
import com.fasterxml.jackson.annotation.JsonProperty;
import javax.annotation.Nonnull;
import java.math.BigDecimal;
public class AvailableDaysResponse
{
@JsonProperty( "availableDays" )
private final BigDecimal days;
public AvailableDaysResponse( @Nonnull final BigDecimal days ) {
this.days = days;
}
}
controllers/EntitlementsController.java
to listen on requests to our application. Depending on the request this controller evaluates items from the time account of a user. Either the result is computed on the current date (/entitlements/[USERID]/timeaccount
), or the on a given specific year (/entitlements/[USERID]/timeaccount/[YEAR]
). For easy processing, the intermediate list of TimeAccountDetail is filtered for values of type "DAYS" and accumulated. As a result, the numeric sum is wrapped into our own model class AvailableDaysResponse
. You can later choose your own algorithm to manipulate the list of accountDetails
, and maybe use a different response model for further data processing.package org.example.sfsf.controllers;
import com.netflix.hystrix.exception.HystrixRuntimeException;
import com.sap.cloud.sdk.cloudplatform.logging.CloudLoggerFactory;
import org.example.sfsf.GetEntitlementsCommand;
import org.example.sfsf.models.AvailableDaysResponse;
import org.example.sfsf.namespaces.timeoff.TimeAccountDetail;
import org.example.sfsf.services.TimeOffService;
import org.slf4j.Logger;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.WebRequest;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
import javax.annotation.Nonnull;
import static java.time.temporal.TemporalAdjusters.lastDayOfYear;
@RestController
@RequestMapping( "/entitlements" )
public class EntitlementsController
{
private static final Logger logger = CloudLoggerFactory.getLogger(EntitlementsController.class);
private final TimeOffService service;
private final Clock clock;
public EntitlementsController( @Nonnull final TimeOffService service, @Nonnull final Clock clock ) {
this.service = service;
this.clock = clock;
}
@RequestMapping( value = "/{userId}/timeaccount", method = RequestMethod.GET, produces = "application/json" )
public AvailableDaysResponse getDaysAvailableInCurrentYear( @PathVariable( "userId" ) final String userId )
{
final LocalDateTime now = LocalDateTime.now(clock);
final LocalDateTime startDate = now.toLocalDate().withDayOfYear(1).atStartOfDay();
final List<TimeAccountDetail> accountDetails =
new GetEntitlementsCommand(service, userId, startDate, now).execute();
final BigDecimal availableDays =
accountDetails
.stream()
.filter(d -> d.getBookingUnit() != null && d.getBookingUnit().equalsIgnoreCase("DAYS"))
.map(TimeAccountDetail::getBookingAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return new AvailableDaysResponse(availableDays);
}
@RequestMapping( value = "/{userId}/timeaccount/{year}", method = RequestMethod.GET, produces = "application/json" )
public AvailableDaysResponse getDaysAvailableInSpecificYear(
@PathVariable( "userId" ) final String userId,
@PathVariable( "year" ) final int year )
{
final LocalDateTime startDate = LocalDate.ofYearDay(year, 1).atStartOfDay();
final LocalDateTime endDate = startDate.toLocalDate().with(lastDayOfYear()).atTime(LocalTime.MAX);
final List<TimeAccountDetail> accountDetails =
new GetEntitlementsCommand(service, userId, startDate, endDate).execute();
final BigDecimal availableDays =
accountDetails
.stream()
.filter(d -> d.getBookingUnit() != null && d.getBookingUnit().equalsIgnoreCase("DAYS"))
.map(TimeAccountDetail::getBookingAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return new AvailableDaysResponse(availableDays);
}
@ExceptionHandler( HystrixRuntimeException.class )
public ResponseEntity<Object> handleAccessDeniedException( final Exception e, final WebRequest request )
{
logger.debug(
"Failed to interact with SuccessFactors: \"{}\" due to request: {}",
e.getCause().getMessage(),
request,
e);
return new ResponseEntity<>("Failed to load data from SuccessFactors", HttpStatus.BAD_REQUEST);
}
}
GET /entitlements
request. Either only userId is provided or userId and year.HystrixRuntimeException
will be thrown. In this case the error will be handled gracefully, without exposing application internal data to the response.TimeOffService service
and Clock clock
. Both references are Beans and will be automatically injected by the Spring framework.integration-tests
module../integration-tests/src/test/resources/mocked_timeaccount_2019.json
{
"d": {
"results": [
{
"startDate": "/Date(1546300800000)/",
"accountClosed": false,
"endDate": "/Date(1577750400000)/",
"userId": "testuser",
"createdDate": "/Date(1546527324000)/",
"createdDateTime": "/Date(1546545324000+0000)/",
"bookingEndDate": "/Date(1585612800000)/",
"accountType": "TAT_VAC_REC",
"bookingStartDate": "/Date(1546300800000)/",
"timeAccountDetails": {
"results": [
{
"bookingUnit": "DAYS",
"bookingType": "ACCRUAL",
"createdDate": "/Date(1546527352000)/",
"bookingDate": "/Date(1546300800000)/",
"accrualPeriodId": "2019-1",
"bookingAmount": "2.0000000000"
},
{
"bookingUnit": "DAYS",
"bookingType": "ACCRUAL",
"createdDate": "/Date(1547151245000)/",
"bookingDate": "/Date(1548979200000)/",
"accrualPeriodId": "2019-2",
"bookingAmount": "2.0000000000"
},
{
"bookingUnit": "DAYS",
"bookingType": "ACCRUAL",
"createdDate": "/Date(1547151269000)/",
"bookingDate": "/Date(1551398400000)/",
"accrualPeriodId": "2019-3",
"bookingAmount": "2.0000000000"
},
{
"bookingUnit": "DAYS",
"bookingType": "ACCRUAL",
"createdDate": "/Date(1547151292000)/",
"bookingDate": "/Date(1554076800000)/",
"accrualPeriodId": "2019-4",
"bookingAmount": "2.0000000000"
},
{
"bookingUnit": "DAYS",
"bookingType": "ACCRUAL",
"createdDate": "/Date(1547151325000)/",
"bookingDate": "/Date(1556668800000)/",
"accrualPeriodId": "2019-5",
"bookingAmount": "2.0000000000"
},
{
"bookingUnit": "DAYS",
"bookingType": "ACCRUAL",
"createdDate": "/Date(1547151349000)/",
"bookingDate": "/Date(1559347200000)/",
"accrualPeriodId": "2019-6",
"bookingAmount": "2.0000000000"
},
{
"bookingUnit": "DAYS",
"bookingType": "ACCRUAL",
"createdDate": "/Date(1547151373000)/",
"bookingDate": "/Date(1561939200000)/",
"accrualPeriodId": "2019-7",
"bookingAmount": "2.0000000000"
},
{
"bookingUnit": "DAYS",
"bookingType": "ACCRUAL",
"createdDate": "/Date(1547151399000)/",
"bookingDate": "/Date(1564617600000)/",
"accrualPeriodId": "2019-8",
"bookingAmount": "2.0000000000"
},
{
"bookingUnit": "DAYS",
"bookingType": "ACCRUAL",
"createdDate": "/Date(1547151429000)/",
"bookingDate": "/Date(1567296000000)/",
"accrualPeriodId": "2019-9",
"bookingAmount": "2.0000000000"
},
{
"bookingUnit": "DAYS",
"bookingType": "ACCRUAL",
"createdDate": "/Date(1547151455000)/",
"bookingDate": "/Date(1569888000000)/",
"accrualPeriodId": "2019-10",
"bookingAmount": "2.0000000000"
},
{
"bookingUnit": "DAYS",
"bookingType": "ACCRUAL",
"createdDate": "/Date(1547151499000)/",
"bookingDate": "/Date(1572566400000)/",
"accrualPeriodId": "2019-11",
"bookingAmount": "2.0000000000"
},
{
"bookingUnit": "DAYS",
"bookingType": "ACCRUAL",
"createdDate": "/Date(1547151545000)/",
"bookingDate": "/Date(1575158400000)/",
"accrualPeriodId": "2019-12",
"bookingAmount": "2.0000000000"
},
{
"bookingUnit": "DAYS",
"bookingType": "EMPLOYEE_TIME",
"createdDate": "/Date(1546422245000)/",
"bookingDate": "/Date(1553558400000)/",
"accrualPeriodId": null,
"bookingAmount": "-1"
}
]
}
}
]
}
}
./integration-tests/src/test/java/[...]/EntitlementsControllerTest.java
package org.example.sfsf;
import com.github.tomakehurst.wiremock.junit.WireMockRule;
import com.google.common.io.Resources;
import com.sap.cloud.sdk.cloudplatform.servlet.RequestContextExecutor;
import com.sap.cloud.sdk.testutil.MockUtil;
import com.sap.cloud.sdk.testutil.TestConfigurationError;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.LocalDate;
import java.time.ZoneId;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static org.mockito.Mockito.doReturn;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@RunWith( SpringRunner.class )
@WebMvcTest
public class EntitlementsControllerTest
{
private static final MockUtil mockUtil = new MockUtil();
private static final String ODATA_ENDPOINT_URL = "/odata/v2";
private static final String ODATA_METADATA_URL = ODATA_ENDPOINT_URL + "/$metadata";
private static final String RESPONSE_2019 = readResourceFile("mocked_timeaccount_2019.json");
@Rule
public final WireMockRule erpServer = mockUtil.mockErpServer(SuccessFactorsDestination.DESTINATION_NAME, null, null);
@Autowired
private MockMvc mvc;
@MockBean
private Clock clock;
@BeforeClass
public static void beforeClass()
{
mockUtil.mockDefaults();
}
@Before
public void setupMock()
{
// Add default routes for mock server
stubFor(head(urlEqualTo(ODATA_ENDPOINT_URL)).willReturn(ok()));
stubFor(get(urlEqualTo(ODATA_METADATA_URL)).willReturn(okXml(readResourceFile("api/TimeOff.edmx"))));
// Override the current clock for resolving dates during the test
final ZoneId zoneId = ZoneId.systemDefault();
final Clock fixedClock = Clock.fixed(LocalDate.of(2019, 5, 2).atStartOfDay(zoneId).toInstant(), zoneId);
doReturn(fixedClock.instant()).when(clock).instant();
doReturn(fixedClock.getZone()).when(clock).getZone();
}
@Test
public void testCurrentYear()
throws Exception
{
stubFor(get(urlPathEqualTo(ODATA_ENDPOINT_URL + "/TimeAccount")).willReturn(ok().withBody(RESPONSE_2019)));
new RequestContextExecutor().execute(
() -> mvc
.perform(MockMvcRequestBuilders.get("/entitlements/testuser/timeaccount"))
.andExpect(status().isOk())
.andExpect(content().json("{\"availableDays\":9.0000000000}")));
}
private static String readResourceFile( final String s )
{
try {
return Resources.toString(Resources.getResource(s), StandardCharsets.UTF_8);
}
catch( final IOException e ) {
throw new TestConfigurationError(e);
}
}
}
.andExpect(...)
statements to improve test assertions.SpringRunner
and annotated with @WebMvcTest
we can use the autowired MockMvc
instance to directly call our controller.GET /odata/v2/entitlements/testuser/timeaccount
xsuaa
and destination
. For the sake of this guide, let's assume the xsuaa
service instance is called "myxsuaa"
and the destination
service instance is called "mydestination"
.In case you are missing a service instance, go to Service Marketplace and setup it up. For xsuaa
, the recommended service plan is application. For destination
it is lite.SFSF
Just like described in your Java application, as field SuccessFactorsDestination.DESTINATION_NAME
HTTP
https://example.successfactors.com
You can find a list of supported URLs on the Service API page.Internet
BasicAuthentication
Note: Do not use basic authentication in production code.Admin@Company
Please replace the "Admin" user with a technical user you have for the SuccessFactors instance. Also replace "Company" with the company id, which your technical user is connected to. Leave the @
sign as separator.mvn clean install
manifest.yml
env:
...
ALLOW_MOCKED_AUTH_HEADER: true
services:
- myxsuaa
- mydestination
cf
tool, please find the starter tutorial for applications on Cloud Foundry with the SAP Cloud SDK.cf push
urls
above./entitlements/USERID/timeaccount
USERID
enter the SAP SuccessFactors userid for which the available days of current year need to be showed.You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.
User | Count |
---|---|
13 | |
12 | |
11 | |
10 | |
10 | |
9 | |
8 | |
8 | |
8 | |
7 |