PA20 – Workflow-Overview


To attach your own workflow in this overview, you have to add the Task TS51900010 to your workflow and pass over the employee number to the Business Object “Employee” (BUS1065)


PA20 – Workflow-Overview
To attach your own workflow in this overview, you have to add the Task TS51900010 to your workflow and pass over the employee number to the Business Object “Employee” (BUS1065)
I was having a situation, where I needed to access file content via an association. This led to two problems, one in the backend and one in the frontend.
My data-model.cds looked like this.
entity MainEntity: cuid, managed {
file : Association to Files @mandatory @assert.target;
}
entity Files : cuid, managed {
content : LargeBinary @stream @Core.MediaType: mediaType @Core.ContentDisposition.Filename: fileName @Core.ContentDisposition.Type: 'inline';
mediaType : String @Core.IsMediaType: true;
fileName : String @mandatory;
size : Integer;
}
The file content is actually stored in an external system and is only read when the content is explicitly requested, with a call like this:
### Get file content
GET http://localhost:4004/odata/v4/admin/Files({{ID}})/content
Authorization: Basic user:password
For this kind of scenario, I have found the perfect sample code here: https://github.com/SAP-samples/cloud-cap-samples/blob/main/media/srv/media-service.js
But in my case, I needed to call the file content via an association like this:
### Get file content via association
GET http://localhost:4004/odata/v4/admin/MainEntity({{ID}})/file/content
Authorization: Basic user:password
This did not work, because in this case, we don’t get the required file ID in the Files
handler in req.data.ID
(find the reason here), which is needed to read the file from the external system. Therefore, I had to implement the following workaround (line 5-8), which checks from which entity we are coming and is fetching the requested file ID from the DB.
srv.on('READ', Files, async (req, next) => {
//if file content is requested, return only file as stream
if (req.context.req.url.includes('content')) {
// workaround: when File is requested via Association from MainEntity, as the ID is then not provided directly
if (req.context.req.url.includes('MainEntity')) {
req.data.ID = await SELECT.one.from(req.subject).columns('ID')
}
const file = await SELECT.from(Files, req.data.ID)
if (!file) return next() // if file not found, just handover to default handler to get 404 response
try {
const stream = await getMyStreamFromExternalSystem(req)
return [{ value: stream }]
} catch (err) {
req.error(`Could not read file content`)
}
} else return next() // else delegate to next/default handlers without file content
})
This way, the file content can now be read directly via File
and also via MainEntity
following the association.
The next challenge was to display this file content in a Fiori Elements app. This works out of the box, if the file content is called directly from the Files
entity, means not over an association. But if the file content is coming via an association, it seems like the Fiori Elements framework is creating an incorrect backend call. It tries to call the mediaType from the MainEntity
instead of the Files
entity, resulting in a failing odata call, which looks like this
/odata/v4/service/MainEntity(key)/mediaType
instead of /odata/v4/service/MainEntity(key)/file/mediaType
.
The only workaround I found was to overwrite the @Core.MediaType
annotation coming from the Files
entity by setting the mediaType to a hard value in the annotation.yaml of the Fiori Elements App.
annotate service.fileservice@(
UI.FieldGroup #FileGroup : {
$Type: 'UI.FieldGroupType',
Data : [
{
$Type: 'UI.DataField',
label: 'Main ID',
Value: ID,
},
{
$Type: 'UI.DataField',
label: 'File ID',
Value: file.ID,
},
{
$Type: 'UI.DataField',
Value: file.content,
},
{
$Type: 'UI.DataField',
Value: file.mediaType,
},
{
$Type: 'UI.DataField',
Value: file.fileName,
},
{
$Type: 'UI.DataField',
Value: file.size,
},
],
},
UI.Facets : [
{
$Type : 'UI.ReferenceFacet',
ID : 'GeneratedFacet2',
Label : 'File Information',
Target: '@UI.FieldGroup#FileGroup',
},
],
);
// Workaround as currently display file content via an association in Fiori Elements is incorrectly trying to fetch the media type.
// Therefore add a fix value for the media type. Of course, this only works, if you only expect a specific file type.
annotate service.Files with {
@Core.MediaType : 'application/pdf'
content
};
In the Fiori Elements App it will now be displayed like this and by clicking on the Context, it will successfully load the file from the backend:
this.getView().bindElement({
path: sObjectPath,
events: {
dataRequested: (oEvent) => {}, // Executed when a request to server is send
dataReceived: (oEvent) => {}, // Executed when data from server is received
change:(oEvent) => {}, // Executed everytime you do ElementBinding
}
})
The events for dataRequested
and dataReceived
are only fired, when data is requested or data is received from a backend. This is not the case, when the requested data is already available in the model from a previous backend call. In such situations, the change
event comes in handy.
The same can also be done via XML:
binding="{
path: '/myEntitySet',
events: {
dataRequested: 'onDataRequested',
dataReceived: 'onDataReceived',
change: 'onDataChange'
}
}"
This URL will return details about the logged-in user. The identity provider can either be the default or a custom identity provider configured in the BTP Trust Configuration. The response will differ for OpenID Connect and SAML protocol.
https://<domain>.authentication.<region>.hana.ondemand.com/config?action=who&details=true
https://docs.cloudfoundry.org/api/uaa/version/4.6.0/index.html#password-grant
# url from XSUAA Service Key, but replace in the url the provider subdomain with the consumer subdomain (the tenant you want to call)
@xsuaaUrl = {{$dotenv xsuaaUrl}}
# clientid from XSUAA Service Key
@xsuaaClientId = {{$dotenv xsuaaClientId}}
# clientsecret from XSUAA Service Key
@xsuaaClientSecret = {{$dotenv xsuaaClientSecret}}
@username = {{$dotenv btp_username}}
@password = {{$dotenv btp_password}}
### Get Access Token for Cloud Foundry using Password Grant with BTP default IdP
# @name getXsuaaToken
POST {{xsuaaUrl}}/oauth/token
Accept: application/json
Authorization: Basic {{xsuaaClientId}}:{{xsuaaClientSecret}}
Content-Type: application/x-www-form-urlencoded
grant_type=password
&username={{username}}
&password={{password}}
&response_type=token
### Store access token
@access_token = {{getXsuaaToken.response.body.$.access_token}}
SELECT * FROM sflight INTO TABLE @DATA(flights).
LOOP AT flights INTO DATA(flight).
WAIT UP TO 1 SECONDS.
cl_progress_indicator=>progress_indicate( i_text = |Processing flight { flight-connid } as { sy-tabix } / { lines( flights ) }|
i_processed = sy-tabix
i_total = lines( flights )
i_output_immediately = abap_true ).
ENDLOOP.
For Jobs running longer than 15 seconds, you have to manually inform the Job Scheduler if your operation succeeded or not. Else, your job will only stay in status COMPLETED/UNKNOWN due to the timeout.
Informing the Job Scheduler about your succeeded operation can be done vie REST API Endpoint Update Job Run Log. You can read more about Long-Running (Async) Jobs here. I therefore wrote a function named updateJobStatus, which I call at the end of every long-running endpoint. It checks if the endpoint is called manually or via Job Scheduler service and updates the Job Run Log using the @sap/jobs-client if required.
const cds = require('@sap/cds')
const LOG = cds.log('JobService')
const xsenv = require("@sap/xsenv")
const JobSchedulerClient = require("@sap/jobs-client")
async function fetchAccessToken(url, creds) {
const response = await fetch(`${url}/oauth/token`, {
method: 'POST',
body: 'grant_type=client_credentials&client_id=' + creds.uaa.clientid + '&client_secret=' + creds.uaa.clientsecret,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
})
return await response.json()
}
async function getJobscheduler(req) {
xsenv.loadEnv()
const services = xsenv.getServices({
jobscheduler: { tags: "jobscheduler" }
})
if (!services.jobscheduler) req.reject("no jobscheduler service instance found")
const subdomain = (process.env.NODE_ENV === 'production') ? req.http.req.authInfo.getSubdomain() : 'customer1' // workaround for local testing
const domain = `https://${subdomain}.${services.jobscheduler.uaa.uaadomain}`
const token = await fetchAccessToken(domain, services.jobscheduler)
const options = {
baseURL: services.jobscheduler.url,
token: token.access_token
}
return new JobSchedulerClient.Scheduler(options)
}
async function updateJobStatus(req) {
const jobId = req.headers['x-sap-job-id']
const scheduleId = req.headers['x-sap-job-schedule-id']
const runId = req.headers['x-sap-job-run-id']
if (!jobId || !scheduleId || !runId) return
LOG.info('Endpoint is called via Job Scheduler')
const scheduler = await getJobscheduler(req)
const payload = {
jobId: jobId,
scheduleId: scheduleId,
runId: runId,
data: { success: true, message: 'The endpoint has successfully executed the long-running job' }
}
scheduler.updateJobRunLog(payload, function (err, result) {
if (err) return LOG.error('Error updating run log: %s', err)
//Run log updated successfully
LOG.info('Run log updated successfully')
})
}
module.exports = {
updateJobStatus
}
When I was integrating the Job Scheduler service into my Multitenant Application, I ran into the following JWT Token issue, when the Job Scheduler was calling my CAP action. Means the job creation was already working fine and was also displaying the right tenant for my job, but the Job Scheduler was not able to successfully call the given endpoint. This is the error I got in the logs:
Error: Jwt token with audience: [
'sb-a1e9d3b8-2bee-47db-xxxx-07e5a54aec1e!b180208|sap-jobscheduler!b3',
'uaa'
] is not issued for these clientIds: [
'sb-MyApp-mtdev-App!t180208',
'MyAp-mtdev-App!t180208'
].
After reading some of the great blogs from Carlos Roggan, I noticed that I forgot to grant the Job Scheduler the necessary authority to actual call my CAP action. So I added the following lines to the xs-security.json file
{
"name": "$XSAPPNAME.jobscheduler",
"description": "Scope for Job Scheduler",
"grant-as-authority-to-apps": [
"$XSSERVICENAME(job-scheduler)"
]
}
and also annotated my CAP action using the new scope @(requires: ['jobscheduler'])
.
I redeployed everything, but the issue still persists. 🙁
Turned out, for the standard plan, tokens are cached in Job Scheduler up to 12 hours.
https://help.sap.com/docs/job-scheduling/sap-job-scheduling-service/secure-access?locale=en-US
After waiting 12 hours, the endpoint was successfully called by the Job Scheduler. 🙂
In my application, I have a function that can take quite a long time to process, depending on the data selected. Two external systems were involved in the processing, so a lot of round trips were made. Of course, I tried to parallelize the calls to the external systems as much as possible, but it still took a long time. During the development in BAS everything worked fine, but during the deployment in BTP I encountered some errors, depending on the amount of data selected.
In the console, I could see, that it was a 504 Gateway Timeout.
Luckily, the CAP docs are already explaining the possible reason for this. The approuter has a default timeout of 30 seconds for destinations. This matched my observation, that this issue only occurred when deployed.
https://www.npmjs.com/package/@sap/approuter#destinations
In my case, the destination for my backend service is configured in the mta.yaml directly on the approuter. By simply adding the timeout property and by increasing the timeout from 30 seconds to 2 minutes, I could get rid of the errors.
- name: my-approuter
type: approuter.nodejs
path: app/approute
build-parameters:
builder: npm-ci
ignore:
- "node_modules/"
- "default-env.json"
- "manifest*.yml"
requires:
- name: srv-api
group: destinations
properties:
name: srv-api
url: ~{srv-url}
forwardAuthToken: true
timeout: 120000 # <--------------------------------- add timeout to your cap service destination
- name: my-xsuaa
- name: my-destination
- name: my-html5-repo-runtime