integration

SuccessFactors-Integrationen mit SAP CI in komponierbare APIs verwandeln

Parth Dhingra · 23. Juli 2026

Dieser Beitrag wurde aus dem englischen Original übersetzt.

Die meisten Integrationen auf SAP Cloud Integration, die HR-Stammdaten replizieren, sind als geplante Batch-Jobs gebaut: Ein Timer feuert, eine feste OData-Abfrage läuft, und die Ergebnisse werden nachgelagert weitergereicht. Dieser iFlow geht einen grundlegend anderen Weg: Er wird als Composable API bereitgestellt. Ein externer Aufrufer entscheidet, wann er läuft, welchen Modus er nutzt und wie viele Datensätze er pro Seite abholt. Die Integrationsplattform verhält sich weniger wie ein Cron-Job und mehr wie ein RESTful Service mit SuccessFactors im Rücken.

Kernidee: Indem das Ausführungsmodell an einen eingehenden HTTP-Aufruf gebunden wird, kann dieselbe Integration geplante Orchestratoren, On-Demand-Provisioning-Workflows und paginierte Massenexporte bedienen, ohne die Logik über mehrere Integrationsartefakte hinweg zu duplizieren.

Konfiguration des HTTP-Endpunkts

Der HTTP Sender Adapter ist mit einem Wildcard-Pfad konfiguriert:

Dadurch kann der Integrationsflow mehrere Endpunktvarianten dynamisch annehmen und verarbeiten, während das Groovy-Skript das Routing und den Aufbau der Abfrage intern übernimmt, wie unten beschrieben.

Run Modes - Full, Delta und Custom

Das pfadbasierte Routing geschieht im Groovy-Skript. Es untersucht CamelHttpPath und CamelHttpQuery, baut den passenden OData-Query-String, legt ihn als Property sfQuery ab und setzt das Flag updateLSRD, um zu steuern, ob die Last Run Date Time nach einem erfolgreichen Lauf persistiert werden soll.

1. Parameter initialisieren sowie SuccessFactors-Abfrage und Seitengröße vorbelegen

Das Skript liest zunächst die Routing-Eingaben und definiert die Basisprojektion aus $select und $expand. Diese Projektion ist pfadunabhängig, nur Filter und Effective Dating ändern sich je Pfad.

    def headers = message.getHeaders()
    def properties = message.getProperties()
    def path = headers.get("CamelHttpPath")
    def queryParams = headers.get("CamelHttpQuery")
    def lastRunDateTime = properties.get("LastRunDateTime")
    def updateLSRD = "false"
    def customQuery = ""
    def sfQuery = """\$select=personIdExternal,personId,employmentNav/personIdExternal,employmentNav/jobInfoNav/company,employmentNav/jobInfoNav/companyNav/name,employmentNav/jobInfoNav/countryOfCompanyNav/externalCode,employmentNav/jobInfoNav/countryOfCompanyNav/localeLabel,employmentNav/jobInfoNav/department,employmentNav/jobInfoNav/departmentNav/name,employmentNav/jobInfoNav/employeeClassNav/externalCode,employmentNav/jobInfoNav/employeeClassNav/localeLabel,employmentNav/jobInfoNav/employmentTypeNav/externalCode,employmentNav/jobInfoNav/employmentTypeNav/localeLabel,employmentNav/jobInfoNav/costCenter,employmentNav/jobInfoNav/emplStatusNav/externalCode,employmentNav/jobInfoNav/emplStatusNav/localeLabel&\$expand=employmentNav,employmentNav/jobInfoNav,employmentNav/jobInfoNav/companyNav,employmentNav/jobInfoNav/countryOfCompanyNav,employmentNav/jobInfoNav/departmentNav,employmentNav/jobInfoNav/employeeClassNav,employmentNav/jobInfoNav/employmentTypeNav,employmentNav/jobInfoNav/emplStatusNav"""
    def pageSize = "200" // default

2. Die Seitengröße auflösen

$pagesize ist ein optionaler Parameter. Liefert der Aufrufer ihn mit, wird er verwendet, andernfalls greift der Standardwert.

if (queryParams && queryParams.toLowerCase().contains("\$pagesize=")) {
        try {
            pageSize = queryParams.split("(?i)\\\$pagesize=")[1].split("&")[0]
        } catch (Exception e) {
            pageSize = "200"
        }
    }
 
 customQuery = "customPageSize=" + pageSize

Beispiel-Request:

3. SuccessFactors-Abfrage für den Full-Modus setzen.

Hängt &asOfDate=9999-12-31 an die OData-Abfrage an, sodass SuccessFactors alle Datensätze zurückgibt, die im Mandanten bis in die ferne Zukunft gültig sind. Der Datumswert ist im Skript fest hinterlegt und lässt sich anpassen, um das Gültigkeitsfenster einzuengen oder zu verschieben (etwa auf das heutige Datum für ausschließlich aktive Datensätze oder auf ein nahes Zukunftsdatum für die Sichtbarkeit von Pre-Hires). Setzt updateLSRD=true, damit der Zeitstempel des Laufs nach Abschluss persistiert wird.

 if (path?.toUpperCase()?.contains("FULL")) {
        updateLSRD = "true"
        sfQuery = sfQuery + "&asOfDate=9999-12-31"
    }

4. SuccessFactors-Abfrage für den Delta-Modus setzen.

Der Endpunkt /delta ist darauf ausgelegt, inkrementelle Änderungen (Delta) über mehrere Entitäten hinweg effizient abzuholen, indem ein einheitlicher Zeitstempelfilter auf lastModifiedDateTime angewendet wird.

else if (path?.toUpperCase()?.contains("DELTA")) {
        if (queryParams) {
            queryParams = queryParams.replaceAll("%20", " ").replaceAll("%27", "'")

            // Separate $pagesize from other params
            def extraParams = []
            queryParams.split("&").each { param ->
                if (!param.toLowerCase().startsWith("\$pagesize")) {
                    extraParams << param
                }
            }

            if (extraParams) {
                // Try to build delta filter from first extra param
                def filterParts = extraParams[0].split(" ")
                if (filterParts.size() >= 3) {
                    def qualifier = filterParts[1]
                    def queryDateTime = filterParts[2]
                    def deltaQuery = """&\$filter=lastModifiedDateTime $qualifier datetimeoffset$queryDateTime or employmentNav/lastModifiedDateTime $qualifier datetimeoffset$queryDateTime or employmentNav/jobInfoNav/lastModifiedDateTime $qualifier datetimeoffset$queryDateTime"""
                    sfQuery = sfQuery + deltaQuery
                } else {
                    // if not a filter, just append param(s) as-is
                    extraParams.each { p -> sfQuery = sfQuery + "&" + p }
                }
            } else {
                // Query had ONLY $pagesize → fallback to LastRunDateTime
                def query = """&\$filter=lastModifiedDateTime ge datetimeoffset'$lastRunDateTime' or employmentNav/lastModifiedDateTime ge datetimeoffset'$lastRunDateTime' or employmentNav/jobInfoNav/lastModifiedDateTime ge datetimeoffset'$lastRunDateTime'"""
                sfQuery = sfQuery + query
                updateLSRD = "true"
            }

        } else {
            // Truly no query params → use LastRunDateTime
            def query = """&\$filter=lastModifiedDateTime ge datetimeoffset'$lastRunDateTime' or employmentNav/lastModifiedDateTime ge datetimeoffset'$lastRunDateTime' or employmentNav/jobInfoNav/lastModifiedDateTime ge datetimeoffset'$lastRunDateTime'"""
            sfQuery = sfQuery + query
            updateLSRD = "true"
        }
    }

So funktioniert es

  • Der Endpunkt baut über das Feld lastModifiedDateTime einen dynamischen Filter über alle relevanten Entitäten hinweg auf.
  • Standardmäßig wird der Filter durch den Wert LastRunDateTime gesteuert, einen Zeitstempel, der aus der vorherigen erfolgreichen Ausführung persistiert wurde (gespeichert immer dann, wenn das Flag updateLSRD auf true steht). Der Aufrufer muss ihn nicht mitliefern, die Integration leitet ihn automatisch aus ihrer eigenen Laufhistorie ab. Beispiel-Request: <baseUrl>/delta?$pagesize=100.
  • Optional kann der Aufrufer dieses Verhalten überschreiben, indem er einen expliziten Filterausdruck übergibt und damit das Extraktionsfenster direkt steuert, statt sich auf die zuletzt gespeicherte Laufzeit zu verlassen.

5. Custom-Pfade

Über Full und Delta hinaus lässt sich dasselbe Muster auf zweckgebundene Endpunkte erweitern. Hier unterstützt /employees eine On-Demand-Suche über die externe Personen-ID, nützlich, wenn ein nachgelagertes System einen einzelnen Datensatz erneut synchronisieren muss, ohne auf den nächsten geplanten Lauf zu warten.

else if (path?.toUpperCase()?.contains("EMPLOYEES")) {
    updateLSRD = "false"

    if (queryParams) {
        // Decode encoded characters
        queryParams = queryParams.replaceAll("%20", " ")
                                 .replaceAll("%27", "'")
                                 .replaceAll("%22", '"')
                                 .replaceAll("%2C", ",")

        // Drop $pagesize, keep everything else
        def extraParams = queryParams.split("&").findAll {
            !it.toLowerCase().startsWith('$pagesize')
        }

        if (!extraParams.isEmpty()) {

            // Only ONE filter expression is allowed
            if (extraParams.size() > 1) {
                throw new IllegalArgumentException(
                    "Invalid request: only a single 'personIdExternal in (...)' filter is supported. Received: " + extraParams.join(" & "))
            }

            def param = extraParams[0].trim()

            
            param = param.replaceFirst(/(?i)^\$filter\s*=\s*/, "").trim()
            def pattern = ~/(?i)^personIdExternal\s+in\s*\(?\s*(['"][^'"]+['"])(\s*,\s*(['"][^'"]+['"]))*\s*\)?$/

            if (!(param ==~ pattern)) {
                throw new IllegalArgumentException(
                    "Invalid request: only queries of the form personIdExternal in \"2\",\"3\" are allowed. Received: " + param)
            }

            sfQuery = sfQuery + '&$filter=' + param
        }
    }
}

6. Message Properties setzen

Der letzte Schritt veröffentlicht die Ergebnisse in den Exchange Properties, damit nachfolgende Schritte des Integrationsflows sie aufgreifen können.

    message.setProperty("sfQuery", sfQuery) // SuccessFactors query and filter
    message.setProperty("updateLSRD", updateLSRD) // boolean flag to update last run
    message.setProperty("customQuery", customQuery) // page size 
    return message

SuccessFactors über OData V2 abfragen

Der iFlow verbindet sich über den SuccessFactors OData V2 Adapter mit SuccessFactors. Ein zentraler Aspekt dieses Designs ist die dynamische Ausführung der Abfrage, die vollständig über Message Properties gesteuert wird statt über fest hinterlegte Werte.

Adapter-Konfiguration

  • Der Adapter ist mit einem festen Resource Path (etwa PerPerson) und einem Operationstyp (Query (GET)) konfiguriert.
  • Statt statische Query-Parameter zu definieren, nutzt die Integration Runtime Properties:
    • sfQuery → enthält den dynamisch aufgebauten OData-Query-String.
    • customQuery → enthält zusätzliche Query-Parameter wie $pagesize .
  • Diese Properties werden früher im Flow per Groovy-Skript befüllt und in die Adapter-Konfiguration injiziert.

Umgang mit dem Pagination Token

SuccessFactors OData V2 nutzt serverseitige Pagination auf Basis von $skiptoken. Übersteigt eine Ergebnismenge die angeforderte Seitengröße, enthält die Antwort einen __next-Link (in CI als Property SkipToken_Fetch_Employee.SuccessFactors sichtbar). Der iFlow implementiert eine Schleife:

  1. Seitengröße setzen Das Groovy-Skript extrahiert $pagesize aus dem eingehenden Query-String (Standard 200) und legt es als customQuery=customPageSize=N ab.
  2. SF-Aufruf ausführen Der Service Task Fetch Employee Data fragt SuccessFactors ab. Ist der Datenbestand größer als die Seitengröße, befüllt der Adapter die Property SkipToken_Fetch_Employee.SuccessFactors mit der URL der nächsten Seite.
  3. Token prüfen (Gateway) Das Exclusive Gateway check token wertet aus, ob ein Skip Token vorliegt. Wenn ja, leitet der Flow zu Processing type weiter, wo geprüft wird, ob im Modus "pagination" fortgefahren werden soll.
  4. nextUrl speichern und bereitstellen Das Groovy-Skript hebt den Wert von SkipToken_Fetch_Employee.SuccessFactors in einen skiptoken-Header und persistiert ihn in einem Data Store für spätere Zugriffe. Der Parameter nexturl_prefix wird vorangestellt, um eine für den Aufrufer bequeme Fortsetzungs-URL zu bauen, die unter /page/{token} zurück in denselben iFlow führt.

  1. Einstiegspunkt für die Pagination Beim Aufruf unter /page/{skipToken} extrahiert das Groovy-Skript das Token aus dem Pfad und setzt den Header skipToken für den Data-Store-Lookup. Der Schritt prepare query baut den SF-Request mit diesem Token neu auf und umgeht dabei die vollständige Logik zum Aufbau der Abfrage.
import com.sap.gateway.ip.core.customdev.util.Message
import java.net.URLDecoder

def Message processData(Message message) {

    def headers = message.getHeaders()
    def nextUrl = headers.get("next_url") ?: ""

    // --- Extract query part only ---
    def queryPart = nextUrl
    if (queryPart.contains("?")) {
        queryPart = queryPart.split("\\?", 2)[1]
    }

    // --- Extract $skiptoken 
    def skipToken = ""
    def m = (queryPart =~ '(?i)(?:^|&)\\$skiptoken=[^&]+')  
    def matches = []
    while (m.find()) {
        matches << m.group()
    }
    if (!matches.isEmpty()) {
        skipToken = matches.last()
        if (skipToken.startsWith("&")) {
            skipToken = skipToken.substring(1) // drop leading &
        }
        // Remove ALL $skiptoken occurrences from query
        queryPart = queryPart.replaceAll('(?i)(?:^|&)\\$skiptoken=[^&]+', "")
        // Clean up leftover connectors
        queryPart = queryPart.replaceAll('&&+', "&")
        queryPart = queryPart.replaceAll('^&', "")
        queryPart = queryPart.replaceAll('&$', "")
    }

    // --- Decode remaining query (turn % encodings into actual chars) ---
    def decodedQuery = URLDecoder.decode(queryPart, "UTF-8")

    // Store results
    message.setProperty("sfQuery", decodedQuery)     // without skiptoken, decoded
    if (skipToken) {
        message.setProperty("customQuery", skipToken) // "$skiptoken=..." without leading "&"
    }

    return message
}

Architektureinblick: Statt alle Seiten intern in einem Lauf abzuholen, gibt der iFlow jeweils eine Seite zurück und stellt dem Aufrufer eine typisierte URL für die nächste Seite bereit. Das verlagert die Pagination-Schleife zum Orchestrator, hält einzelne iFlow-Ausführungen kurzlebig und gibt dem Aufrufer ein natürliches Checkpointing.

Die Enrichment-Pipeline

Nach dem Abholen der Rohdaten der Mitarbeitenden aus SuccessFactors wendet der iFlow eine mehrstufige Enrichment- und Transformations-Pipeline an, bevor das Payload das nachgelagerte Zielsystem erreicht. Enrichment und Transformation richten sich nach dem Ausgabeformat, das die API liefern soll.

Im Rahmen dieses Prozesses extrahiert der Mapping-Schritt MM_skiptoken das skiptoken und wandelt es in ein konsumentenfreundliches Format der Next Page URL um. Diese URL wird anschließend in das Response-Payload eingebettet, sodass Endnutzer die nachfolgenden Datensätze nahtlos abholen können.

Die finale Antwortstruktur enthält beides:

  • Das angereicherte und transformierte Daten-Payload.
  • Die erzeugte Next Page URL für die Fortsetzung der Pagination.

Bekannte Einschränkungen - der Preis dieser Flexibilität

Die Integration als Composable API bereitzustellen verlagert echte Verantwortung auf den Aufrufer, nicht nur, wann Daten konsumiert werden, sondern auch, wie zuverlässig das geschieht. Diese Flexibilität gibt es nicht umsonst:

  • Aufrufer müssen den Token-Status korrekt verwalten. Der Orchestrator ist dafür verantwortlich, die nextUrl beziehungsweise das Skip Token zwischen den Aufrufen zu persistieren und wiederzuverwenden, damit die Pagination korrekt fortgesetzt wird. Bei falscher Verwendung wirft die Integration einen Laufzeitfehler, statt dass das Muster stillschweigend falsche Ergebnisse liefert.
  • Reihenfolge und transaktionale Garantien werden nicht geboten. Wenn ein nachgelagertes System den vollständigen Datenbestand konsistent und in der richtigen Reihenfolge braucht, bedeutet ausgelagerte Pagination, dass diese Garantie im Aufrufer gebaut werden muss und nicht von der Integration angenommen werden darf.
  • On-Demand-Zugriff kann mehr Last bedeuten, nicht weniger. Eine komponierbare, jederzeit aufrufbare API kann zu häufigeren oder Ad-hoc-Aufrufen einladen, als es ein fester Zeitplan täte. Dieses Muster ist also nicht automatisch schonender für die Rate Limits von SuccessFactors, es kann das Risiko lediglich von "geplant und vorhersehbar" zu "stoßweise und aufrufergesteuert" verschieben.

Dieses Muster eignet sich gut für Szenarien mit mehreren, unterschiedlichen Konsumenten desselben HR-Datenbestands, es ist aber kein universeller Ersatz für geplante Batch-Integration. Einfachere Szenarien mit nur einem Konsumenten sind mit einem geradlinigen, timerbasierten iFlow womöglich besser bedient. Die richtige Wahl hängt davon ab, wie viele Aufrufer diese Daten benötigen, wie sie sie konsumieren wollen und wie viel operativen Aufwand das Team zu tragen bereit ist.

Ähnliche Beiträge

Das könnte Sie auch interessieren

Aktualisieren Sie Ihre Unternehmensintegration.

Mit zunehmender Effizienz bei Digitalisierung und Datenfluss erwarten Sie einen höheren Unternehmenswert und eine bessere Wettbewerbsfähigkeit.