Technical Articles
Develop and deploy a HTML (Angular/Vue/React) app on SAP BTP (Cloud Foundry) access S/4 On-premise, Part II
See my previous blog Develop and deploy a HTML (Angular/Vue/React) app on SAP BTP (Cloud Foundry) access S/4 On-premise, Part I | SAP Blogs for the context.
This post provides detail step to develop and deploy a HTML app (based on Angular) which deployed to SAP BTP and access data from two sources:
- S/4 On-premise system;
- A Public API.
Development Steps
Step 1. Create an OData Service in S/4 On-premises system.
It is suggested to use RAP for such development in ABAP development world, but it depends on personal choices.
In my example, I’d created a managed (with Draft) RAP application which can create/display/delete all Test Cases.
With RAP’s ‘Preview’ functionality, the app look likes as following screenshot. And with this RAP, the OData service has been published from S/4 OP system, so that it can be accessed from SAP BTP in the coming steps.
Step 2. Create an empty project.
Add package.json
file as following and run `npm i` to install it.
{
"name": "btp-webapp",
"description": "My Web app on BTP",
"dependencies": {
"mbt": "^1.2.7"
},
"scripts": {
"clean": "npx rimraf mta_archives resources",
"build": "npm run clean && mbt build",
"deploy": "cf deploy mta_archives/btp-webapp_1.0.0.mtar"
},
"version": "1.0.0"
}
If you need push your code into git-based repository, don’t forget to create a `.gitignore` file to skip unnecessary files, especailly the node_modules.
A sample .gitignore file as following:
node_modules/
*/node_modules/
resources/
mta_archives/
*/.angular/
mta-op*/
webapp-content.zip
*/webapp-content.zip
Step 3. Create `router` folder for app router
Create a new folder named `router` under the project created in `Step 2`. Under the new create folder, create package.json as following (also run npm installation after creation):
{
"name": "approuter",
"description": "Node.js based application router service for html5-apps",
"dependencies": {
"@sap/approuter": "^10.10.1",
"axios": "^0.18.0"
},
"scripts": {
"start": "node index.js"
}
}
Create an empty `index.js` in this router folder for now.
Step 4. Create your HTML app (in this case, use Angular)
Under the new create project (in step 2), use `ng new webapp` to create an Angular app. I assume that the Angular CLI has been installed globally. Refer to Angular official website for creating a new Angular program.
After the Angular app create successfully, in folder `webapp`, create manifest.json
:
{
"_version": "1.12.0",
"sap.app": {
"id": "webapp",
"applicationVersion": {
"version": "1.0.1"
}
},
"sap.cloud": {
"public": true,
"service": "cloud.service"
}
}
Though manifest.json file, this HTML app now can be recognized by SAP HTML App. Repository.
Change to this new Angular project:
- Firstly, change angular.json file to change the output path: remove the sub folder. Refer to Angular official document for such change if you don’t know how.
- Then, change the app.component.ts.
- Change the app.component.html.
The angular.json file:
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist",
"index": "src/index.html",
The app.component.ts file:
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
})
export class AppComponent implements OnInit {
title = 'webapp';
arCases: any[] = [];
arProducts: any[] = [];
constructor(private http: HttpClient) {
}
ngOnInit(): void {
}
onFetchDataFromService() {
this.http.get('/v2/Northwind/Northwind.svc/Products?$top=30&$format=json').subscribe({
next: (val: any) => {
this.arProducts = val.d.results.slice();
},
error: err => {
console.error(err);
}
});
}
onFetchDataFromERP() {
const url = `/erp/sap/opu/odata4/sap/zac_fb2testcases_o4/srvd/sap/zac_fb2testcases/0001/TestCases?sap-client=001&$count=true&$select=CaseID,CaseType,CaseTypeName,CaseUUID,Description,ExcludedFlag,LastChangedAt&$skip=0&$top=30`;
this.http.get(url).subscribe({
next: (val: any) => {
this.arCases = val.value.slice();
},
error: err => {
console.error(err);
}
});
}
}
Here there are two methods create to retrieve data from different sources, respectively,
- First one call to ‘service.odata.org’
- Second one call to backend S/4 HANA
The app.component.html look like:
<div>
<button (click)="onFetchDataFromService()">Fetch data from Service.odata.org</button>
<button (click)="onFetchDataFromERP()">Fetch data from ERP</button>
</div>
<h3 class="p-3 text-center">Display a list of cases</h3>
<!-- Content from S/4 OP -->
<div class="container">
<table class="table table-striped table-bordered">
<thead>
<tr>
<th>CaseID</th>
<th>CaseType</th>
<th>CaseTypeName</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let case of arCases">
<td>{{case.CaseID}}</td>
<td>{{case.CaseType}}</td>
<td>{{case.CaseTypeName}}</td>
<td>{{case.Description}}</td>
</tr>
</tbody>
</table>
</div>
<hr />
<!-- Content from Service -->
<h3 class="p-3 text-center">Display a list of products</h3>
<div class="container">
<table class="table table-striped table-bordered">
<thead>
<tr>
<th>ProductID</th>
<th>ProductName</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let prod of arProducts">
<td>{{prod.ProductID}}</td>
<td>{{prod.ProductName}}</td>
</tr>
</tbody>
</table>
</div>
There are two buttons, which call to the two methods above.
The expected app behavior as following:
- Firstly, the app opened as following:
- After clicked ‘Fetch data from Service.odata.org’
- After click ‘Fetch data from ERP’
The HTML app (Angular app in this post) is not yet done.
Enhance ‘package.json’ file with following scripts:
"build-btp": "npm run clean-btp && ng build --configuration production && npm run zip",
"zip": "cd dist/ && npx bestzip ../webapp-content.zip * ../manifest.json",
"clean-btp": "npx rimraf webapp-content.zip dist"
Those three commends:
- build-btp: build the project for BTP runtime information.
- zip: zip the content for BTP
- clean-btp: clean the temporary file and folder
And the last piece of this Angular app is create another file named ‘xs-app.json’:
{
"welcomeFile": "/index.html",
"authenticationMethod": "route",
"logout": {
"logoutEndpoint": "/do/logout"
},
"routes": [
{
"source": "^(.*)$",
"target": "$1",
"service": "html5-apps-repo-rt",
"authenticationType": "xsuaa"
}
]
}
Step 5. Complete the approuter module
After the completion of the angular app, we need enrich the approuter
module by updating the `index.js` file.
This step performed in router folder.
const approuter = require('@sap/approuter');
const axios = require('axios');
const ar = approuter();
ar.beforeRequestHandler.use('/erp', async (req, res, next)=>{
const VCAP_SERVICES = JSON.parse(process.env.VCAP_SERVICES);
const destSrvCred = VCAP_SERVICES.destination[0].credentials;
const conSrvCred = VCAP_SERVICES.connectivity[0].credentials;
// call destination service
const destJwtToken = await _fetchJwtToken(destSrvCred.url, destSrvCred.clientid, destSrvCred.clientsecret);
//console.debug(destJwtToken);
const destiConfi = await _readDestinationConfig('YOUR_ERP', destSrvCred.uri, destJwtToken);
//console.debug(destiConfi);
// call onPrem system via connectivity service and Cloud Connector
const connJwtToken = await _fetchJwtToken(conSrvCred.token_service_url, conSrvCred.clientid, conSrvCred.clientsecret);
//console.debug(connJwtToken);
const result = await _callOnPrem(conSrvCred.onpremise_proxy_host, conSrvCred.onpremise_proxy_http_port, connJwtToken, destiConfi, req.originalUrl, req.method);
res.end(Buffer.from(JSON.stringify(result)));
});
const _fetchJwtToken = async function(oauthUrl, oauthClient, oauthSecret) {
const tokenUrl = oauthUrl + '/oauth/token?grant_type=client_credentials&response_type=token'
const config = {
headers: {
Authorization: "Basic " + Buffer.from(oauthClient + ':' + oauthSecret).toString("base64")
}
};
const response = await axios.get(tokenUrl, config);
return response.data.access_token;
};
// Call Destination Service. Result will be an object with Destination Configuration info
const _readDestinationConfig = async function(destinationName, destUri, jwtToken) {
const destSrvUrl = destUri + '/destination-configuration/v1/destinations/' + destinationName
const config = {
headers: {
Authorization: 'Bearer ' + jwtToken
}
};
const response = await axios.get(destSrvUrl, config);
return response.data.destinationConfiguration;
};
const _callOnPrem = async function(connProxyHost, connProxyPort, connJwtToken, destiConfi, originalUrl, reqmethod) {
const targetUrl = originalUrl.replace("/erp/", destiConfi.URL);
const encodedUser = Buffer.from(destiConfi.User + ':' + destiConfi.Password).toString("base64");
try {
const config = {
headers: {
Authorization: "Basic " + encodedUser,
'Proxy-Authorization': 'Bearer ' + connJwtToken
// 'SAP-Connectivity-SCC-Location_ID': destiConfi.CloudConnectorLocationId
},
proxy: {
host: connProxyHost,
port: connProxyPort
}
}
if (reqmethod === 'GET') {
const response = await axios.get(targetUrl, config);
return response.data;
} else {
}
} catch (error) {
if (error.response) { // get response with a status code not in range 2xx
console.error(error.response.data);
console.error(error.response.status);
console.error(error.response.headers);
} else if (error.request) { // no response
console.error(error.request);
} else { // Something wrong in setting up the request
console.error('Error', error.message);
}
console.error(error.config);
}
};
ar.start();
This file is the key part insider approuter which will handle the call from Angular app starting with `/erp`.
- It will figure out the destination service binding with this app, and replace the prefix `/erp` with the virutal URL in destination service.
- The destination `YOUR_ERP` must exist in destination service.
- And then the logic will perform the call to the final URL with connectivity service.
- The sample code here handles only HTTP `GET` call. It can be extended to all HTTP METHODS easily.
- The sample code using library `axios` and it can be easily switch to a different library such as jQuery.
Add another `xs-app.json` to `router` folder:
{
"welcomeFile": "/index.html",
"authenticationMethod": "route",
"routes": [
{
"source": "/user-api/currentUser$",
"target": "/currentUser",
"service": "sap-approuter-userapi"
},
{
"authenticationType": "none",
"csrfProtection": false,
"source": "^/v2/(.*)$",
"destination": "Northwind"
},
{
"authenticationType": "none",
"csrfProtection": false,
"source": "^/erp/(.*)$",
"target": "/$1",
"destination": "YOUR_ERP"
},
{
"source": "(.*)",
"target": "/webapp/$1",
"service": "html5-apps-repo-rt"
}
]
}
This `xs-app.json` is also the key part which defined the routing rules. From the file:
- Call start with `/erp` will be forwarded to destination ‘YOUR_ERP’.
- Call start with `/v2′ will be forwarded to destination ‘Northwind’.
- Others will default forward to HTML5 app repository runtime.
Step 6. Complete the develop
The final step here is complete the definition and the approach to deploy.
This step performed in project root folder.
Create `destination.json` file:
{
"HTML5Runtime_enabled": true,
"version": "1.0.0",
"init_data": {
"instance": {
"existing_destinations_policy": "update",
"destinations": [
{
"Name": "Northwind",
"Description": "Automatically generated Northwind destination",
"Authentication": "NoAuthentication",
"ProxyType": "Internet",
"Type": "HTTP",
"URL": "https://services.odata.org",
"HTML5.DynamicDestination": true
}
]
}
}
}
This destination defines `Northwind` part which is public to all. The destination ‘YOUR_ERP’ cannot be defined here in code level.
Create `xs-security.json`:
{
"xsappname": "webapp_repo_router",
"tenant-mode": "dedicated",
"description": "Security profile of called application",
"scopes": [
{
"name": "uaa.user",
"description": "UAA"
}
],
"role-templates": [
{
"name": "Token_Exchange",
"description": "UAA",
"scope-references": [
"uaa.user"
]
}
],
"oauth2-configuration": {
"redirect-uris": [
"https://*.us10-001.hana.ondemand.com/login/callback"
]
}
}
Since my BTP account for this post is us10-001.hana.ondemand.com
, the redirect-uris have been put in this way, you need adjust to your BTP account regions accordingly.
Then add the `mta.yaml` file for deploy:
ID: btp-webapp
_schema-version: "2.1"
version: 1.0.0
modules:
- name: AngularWebApp
type: html5
path: webapp
build-parameters:
builder: custom
commands:
- npm run build-btp
supported-platforms: []
- name: webapp_deployer
type: com.sap.application.content
path: .
requires:
- name: webapp_repo_host
parameters:
content-target: true
build-parameters:
build-result: resources
requires:
- artifacts:
- webapp-content.zip
name: AngularWebApp
target-path: resources/
- name: webapp_router
type: approuter.nodejs
path: router
parameters:
disk-quota: 256M
memory: 256M
requires:
- name: webapp_repo_runtime
- name: webapp_conn
- name: webapp_destination
- name: webapp_uaa
resources:
- name: webapp_repo_host
type: org.cloudfoundry.managed-service
parameters:
service: html5-apps-repo
service-plan: app-host
- name: webapp_repo_runtime
parameters:
service-plan: app-runtime
service: html5-apps-repo
type: org.cloudfoundry.managed-service
- name: webapp_destination
type: org.cloudfoundry.managed-service
parameters:
service-plan: lite
service: destination
path: ./destination.json
- name: webapp_uaa
parameters:
path: ./xs-security.json
service-plan: application
service: xsuaa
type: org.cloudfoundry.managed-service
- name: webapp_conn
type: org.cloudfoundry.managed-service
parameters:
service-plan: lite
service: connectivity
Step 7. Deploy it
After all steps above completed, you can deploy the change to your CF space.
After run `npm run deploy` on project root folder, the deploy will take place after you have logon to your CF account/space successfully.
Step 8. Final step
After develop and deploy, your HTML app now available in your BTP account.
But if you test it, you will find the `fetch data from ERP` won’t work while `fetch data from service.odata.org` works fine.
The reason behind is, the destination service still one final step: add your destination (setup in SAP Cloud Connection, and defined in your BTP subaccount) into destination service.
You can download the destination service from your subaccount, and then upload to the destination service.
After the deploy, those services shall be runnable in your BTP sub account. Choose the ‘…’ button of your destination, and choose ‘View Dashboard’, and upload your destination file there, and do not forget enter your password (the download destination won’t store password for you) again and ensure the connection is working.
After the destination is defined, then you completed the whole steps.
Open your browser for testing, it shall work:
===
In coming Part III, I would like to describe the second approach by using SAP CAP to achieve same target: ‘a HTML app on SAP BTP (Cloud Foundry) to access S/4 OP system).